init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
report.md
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,34 @@
|
||||
.PHONY: build run test clean tidy docker
|
||||
|
||||
# 构建二进制文件
|
||||
build:
|
||||
go build -o bin/server ./example
|
||||
|
||||
# 开发模式运行
|
||||
run:
|
||||
go run ./example
|
||||
|
||||
# 运行测试
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# 清理
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -rf logs/
|
||||
|
||||
# 安装依赖
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# 生成 Swagger 文档
|
||||
swagger:
|
||||
swag init -g example/main.go -o example/swagger
|
||||
|
||||
# Docker 构建
|
||||
docker:
|
||||
docker build -t xlgo-app:latest .
|
||||
|
||||
# Docker 运行
|
||||
docker-run:
|
||||
docker run -d -p 8080:8080 xlgo-app:latest
|
||||
@@ -0,0 +1,701 @@
|
||||
# xlgo Web Framework
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=for-the-badge&logo=go" alt="Go Version">
|
||||
<img src="https://img.shields.io/badge/Gin-v1.9-00ADD8?style=for-the-badge" alt="Gin Version">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License">
|
||||
</p>
|
||||
|
||||
xlgo 是一个基于 Go + Gin 的轻量级 Web 开发框架,提供了完整的后端开发基础设施,包括配置管理、数据库访问、缓存、认证、日志、文件存储等常用功能。
|
||||
|
||||
## 框架特性
|
||||
|
||||
- **配置管理** - 支持 YAML 配置文件,环境变量覆盖,配置热更新
|
||||
- **数据库** - MySQL + GORM,支持自动迁移、重试机制、连接池、读写分离
|
||||
- **缓存** - Redis 缓存,支持分布式缓存、键前缀、TTL,SCAN 优化
|
||||
- **认证** - JWT 认证,支持 Token 黑名单、刷新机制
|
||||
- **日志** - 分级日志(API、数据库),日志轮转
|
||||
- **中间件** - CORS、限流、日志、认证、CSRF 防护
|
||||
- **文件存储** - 本地存储 + 阿里云 OSS 支持
|
||||
- **实时通信** - SSE 流式响应 + WebSocket 支持
|
||||
- **定时任务** - 内置任务调度器
|
||||
- **验证器** - 请求参数验证,支持自定义错误消息
|
||||
- **错误处理** - 统一错误码体系
|
||||
- **CLI 工具** - 脚手架工具,快速创建项目和代码
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装
|
||||
|
||||
```bash
|
||||
# 安装脚手架工具
|
||||
go install github.com/EthanCodeCraft/xlgo-core/cmd/xlgo@latest
|
||||
|
||||
# 创建新项目
|
||||
xlgo new myproject
|
||||
|
||||
# 进入目录
|
||||
cd myproject
|
||||
|
||||
# 安装依赖
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
### 2. 创建配置文件 config.yaml
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
mode: development
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
password: your_password
|
||||
name: your_database
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key
|
||||
expire: 86400
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
local:
|
||||
path: ./public
|
||||
base_url: http://localhost:8080/public
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
max_size: 100
|
||||
max_backups: 30
|
||||
max_age: 30
|
||||
compress: true
|
||||
```
|
||||
|
||||
### 3. 运行项目
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
访问 http://localhost:8080/health 检查服务状态。
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 配置管理
|
||||
|
||||
```go
|
||||
// 加载配置
|
||||
cfg, err := config.Load("./config.yaml")
|
||||
|
||||
// 获取配置
|
||||
cfg := config.Get()
|
||||
serverPort := cfg.Server.Port
|
||||
|
||||
// 配置热更新
|
||||
cfg, err := config.LoadWithWatch("./config.yaml", func(newCfg *config.Config) {
|
||||
log.Println("配置已更新")
|
||||
})
|
||||
|
||||
// 注册配置变更回调
|
||||
config.RegisterCallback(func(cfg *config.Config) {
|
||||
// 处理配置变更
|
||||
})
|
||||
|
||||
// 手动重新加载
|
||||
config.Reload()
|
||||
```
|
||||
|
||||
### 数据库操作
|
||||
|
||||
```go
|
||||
// 初始化 MySQL
|
||||
database.InitMySQL(cfg)
|
||||
defer database.Close()
|
||||
|
||||
// 主从读写分离
|
||||
database.InitMySQLWithReplicas(cfg, []string{
|
||||
"root:pass@tcp(slave1:3306)/db",
|
||||
"root:pass@tcp(slave2:3306)/db",
|
||||
})
|
||||
|
||||
// 读操作自动路由到从库
|
||||
users := database.GetReadDB().Find(&users)
|
||||
|
||||
// 强制使用主库
|
||||
ctx := database.UseMaster(context.Background())
|
||||
database.GetDBFromContext(ctx).Find(&users)
|
||||
|
||||
// 事务
|
||||
database.Transaction(func(tx *gorm.DB) error {
|
||||
return tx.Create(&user).Error
|
||||
})
|
||||
|
||||
// 健康检查
|
||||
status := database.HealthCheck()
|
||||
```
|
||||
|
||||
### Repository 泛型 CRUD
|
||||
|
||||
```go
|
||||
// 创建仓库
|
||||
userRepo := repository.NewBaseRepo[model.User](database.GetDB())
|
||||
|
||||
// 基础 CRUD
|
||||
user, err := userRepo.FindByID(ctx, 1)
|
||||
err := userRepo.Create(ctx, &user)
|
||||
err := userRepo.Update(ctx, &user)
|
||||
err := userRepo.Delete(ctx, 1)
|
||||
|
||||
// 分页查询
|
||||
result, err := userRepo.FindPage(ctx, 1, 20)
|
||||
// result.Items, result.Total, result.Page, result.PageSize
|
||||
|
||||
// 条件查询
|
||||
users, err := userRepo.FindWhere(ctx, "status = ?", 1)
|
||||
|
||||
// 链式查询
|
||||
users, err := userRepo.NewQueryBuilder().
|
||||
Where("status = ?", 1).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Find(ctx)
|
||||
|
||||
// 批量操作
|
||||
err := userRepo.CreateBatch(ctx, users)
|
||||
err := userRepo.DeleteBatch(ctx, []uint{1, 2, 3})
|
||||
|
||||
// 事务支持
|
||||
err := userRepo.WithTransaction(ctx, func(txRepo *repository.BaseRepo[model.User]) error {
|
||||
return txRepo.Create(ctx, &user)
|
||||
})
|
||||
```
|
||||
|
||||
### Redis 缓存
|
||||
|
||||
```go
|
||||
// 初始化缓存
|
||||
cache.Init()
|
||||
|
||||
// 使用缓存
|
||||
ctx := context.Background()
|
||||
cacheService := cache.GetCache()
|
||||
|
||||
// 设置缓存
|
||||
cacheService.Set(ctx, "user:1", user, 10*time.Minute)
|
||||
|
||||
// 获取缓存
|
||||
var user User
|
||||
if cacheService.Get(ctx, "user:1", &user) {
|
||||
// 缓存命中
|
||||
}
|
||||
|
||||
// 删除缓存
|
||||
cacheService.Delete(ctx, "user:1")
|
||||
|
||||
// 按模式删除(使用 SCAN 优化)
|
||||
cacheService.DeleteByPattern(ctx, "user:*")
|
||||
```
|
||||
|
||||
### JWT 认证
|
||||
|
||||
```go
|
||||
// 生成 Token(自动包含 JTI)
|
||||
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
|
||||
|
||||
// 解析 Token
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
|
||||
// 使 Token 失效(使用 JTI,高效)
|
||||
jwt.InvalidateToken(tokenString)
|
||||
|
||||
// 获取 Token 的 JTI
|
||||
jti, err := jwt.GetJTI(tokenString)
|
||||
```
|
||||
|
||||
**JWT 黑名单优化**:使用 JTI(JWT ID)替代完整 Token 存储,大幅节省 Redis 内存。
|
||||
|
||||
### 分布式锁
|
||||
|
||||
```go
|
||||
// 安全加锁(返回 LockToken)
|
||||
token, err := cache.NewLock(ctx, cache.KLock("order:123"), 30*time.Second)
|
||||
if token != nil {
|
||||
defer cache.Unlock(ctx, token) // 只有持有者能释放
|
||||
// 执行业务逻辑
|
||||
}
|
||||
|
||||
// 续期锁(长任务场景)
|
||||
cache.ExtendLock(ctx, token, 30*time.Second)
|
||||
|
||||
// 自动续期执行
|
||||
err := cache.WithLockAutoExtend(ctx, key, 30*time.Second, 10*time.Second, func() error {
|
||||
// 长任务自动续期
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
**分布式锁安全特性**:使用 Lua 脚本 + UUID Token,只有锁的持有者才能释放。
|
||||
|
||||
### Redis 分布式限流
|
||||
|
||||
```go
|
||||
// 内存限流(单实例)
|
||||
r.Use(middleware.CustomRateLimit(100, time.Minute))
|
||||
|
||||
// Redis 分布式限流(多实例共享)
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100))
|
||||
r.Use(middleware.LoginRedisRateLimit()) // 登录限流
|
||||
r.Use(middleware.APIRedisRateLimit()) // API 限流
|
||||
```
|
||||
|
||||
### 请求验证
|
||||
|
||||
```go
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" label:"用户名" validate:"required,username" msg_required:"请输入用户名"`
|
||||
Password string `json:"password" label:"密码" validate:"required,password" error:"密码格式不正确"`
|
||||
Phone string `json:"phone" label:"手机号" validate:"omitempty,phone" msg_phone:"请输入正确的手机号"`
|
||||
}
|
||||
|
||||
// 绑定并验证
|
||||
errors, ok := validation.ShouldBindAndValidate(c, &req)
|
||||
if !ok {
|
||||
response.Fail(c, errors.FirstMessage())
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**验证器支持的自定义标签**:
|
||||
|
||||
- `label:"中文名"` - 字段显示名称
|
||||
- `error:"通用错误消息"` - 所有验证失败时显示
|
||||
- `msg_required:"必填项"` - 特定规则的错误消息
|
||||
- `msg_min:"最少5个字符"` - 针对 min 规则的错误消息
|
||||
|
||||
### 统一错误码
|
||||
|
||||
```go
|
||||
// 使用预定义错误
|
||||
response.FailWithError(c, response.ErrUserNotFound)
|
||||
|
||||
// 带详细信息
|
||||
response.FailWithDetail(c, response.ErrPasswordWrong, "连续错误3次将锁定账户")
|
||||
|
||||
// 自定义错误
|
||||
err := response.NewError(10001, "自定义错误")
|
||||
```
|
||||
|
||||
**预定义错误码**:
|
||||
|
||||
- 用户模块:`ErrUserNotFound`, `ErrPasswordWrong`, `ErrTokenExpired` 等
|
||||
- 文件模块:`ErrFileTooLarge`, `ErrFileTypeInvalid` 等
|
||||
- 数据模块:`ErrDataNotFound`, `ErrDataConflict` 等
|
||||
|
||||
### 文件上传
|
||||
|
||||
```go
|
||||
// 上传文件
|
||||
file, err := c.FormFile("file")
|
||||
path, err := storage.Upload(file, "images")
|
||||
url := storage.GetURL(path)
|
||||
|
||||
// 上传字节数组
|
||||
path, err := storage.UploadFromBytes(data, "filename.jpg", "images")
|
||||
|
||||
// 删除文件
|
||||
err := storage.Delete(path)
|
||||
|
||||
// 获取文件内容
|
||||
data, err := storage.Get(path)
|
||||
|
||||
// 检查文件是否存在
|
||||
exists := storage.Exists(path)
|
||||
```
|
||||
|
||||
### SSE 流式响应
|
||||
|
||||
```go
|
||||
// AI 对话场景
|
||||
func ChatHandler(c *gin.Context) {
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, chunk := range aiResponse {
|
||||
ch <- chunk
|
||||
}
|
||||
}()
|
||||
sse.StreamChunks(c, ch)
|
||||
}
|
||||
|
||||
// 带消息 ID 的流式响应
|
||||
sse.StreamWithID(c, "msg_123", ch)
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
|
||||
```go
|
||||
// 简单使用
|
||||
r.GET("/ws", ws.HandleFunc(func(conn *ws.Connection, message []byte) {
|
||||
conn.SendText("收到: " + string(message))
|
||||
}))
|
||||
|
||||
// 广播模式
|
||||
hub := ws.NewHub()
|
||||
go hub.Run()
|
||||
hub.Broadcast([]byte("广播消息"))
|
||||
```
|
||||
|
||||
### 定时任务
|
||||
|
||||
```go
|
||||
// 每隔 5 分钟执行
|
||||
cron.AddTask("cleanup", cron.Every(5*time.Minute), func(ctx context.Context) error {
|
||||
return cleanupOldData()
|
||||
})
|
||||
|
||||
// 每天凌晨 2 点执行
|
||||
cron.AddTask("daily_report", cron.Daily(2, 0), generateReport)
|
||||
|
||||
// 每周一上午 9 点执行
|
||||
cron.AddTask("weekly", cron.Weekly(time.Monday, 9, 0), weeklyTask)
|
||||
|
||||
// 完整 Cron 表达式
|
||||
cron.AddTask("every15min", cron.ParseCron("*/15 * * * *"), doSomething)
|
||||
cron.AddTask("monthly", cron.ParseCron("0 0 1 * *"), doSomething) // 每月1号
|
||||
|
||||
// 启动调度器
|
||||
cron.Start()
|
||||
defer cron.Stop()
|
||||
```
|
||||
|
||||
### CSRF 防护
|
||||
|
||||
```go
|
||||
// 基本使用
|
||||
r.Use(middleware.CSRF())
|
||||
|
||||
// 获取 Token(返回给前端)
|
||||
token := middleware.GetCSRFToken(c)
|
||||
|
||||
// 跳过指定路径
|
||||
r.Use(middleware.CSRFWithSkip([]string{"/api/webhook"}))
|
||||
|
||||
// API 模式(双重提交 Cookie)
|
||||
r.Use(middleware.DoubleSubmitCookie())
|
||||
```
|
||||
|
||||
### 密码加密
|
||||
|
||||
```go
|
||||
// 加密密码
|
||||
hash, err := validation.HashPassword("password123")
|
||||
|
||||
// 验证密码
|
||||
if validation.CheckPassword(hash, "password123") {
|
||||
// 密码正确
|
||||
}
|
||||
|
||||
// 带成本升级的验证
|
||||
match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 中间件
|
||||
|
||||
### 认证中间件
|
||||
|
||||
```go
|
||||
// JWT 认证(必须登录)
|
||||
r.Use(middleware.AuthRequired())
|
||||
|
||||
// 管理员权限
|
||||
r.Use(middleware.AdminRequired())
|
||||
|
||||
// 超级管理员权限
|
||||
r.Use(middleware.SuperAdminRequired())
|
||||
|
||||
// 员工权限
|
||||
r.Use(middleware.StaffRequired())
|
||||
|
||||
// 任意用户(管理员或员工)
|
||||
r.Use(middleware.AnyUserRequired())
|
||||
|
||||
// 获取用户信息
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
userType := middleware.GetUserType(c)
|
||||
```
|
||||
|
||||
### 限流中间件
|
||||
|
||||
```go
|
||||
// 登录限流(每分钟 10 次)
|
||||
r.POST("/login", middleware.LoginRateLimit(), handler.Login)
|
||||
|
||||
// 上传限流(每分钟 20 次)
|
||||
r.POST("/upload", middleware.UploadRateLimit(), handler.Upload)
|
||||
|
||||
// 自定义限流
|
||||
r.Use(middleware.CustomRateLimit(50, time.Minute))
|
||||
|
||||
// 停止限流器(应用关闭时)
|
||||
defer middleware.StopRateLimiters()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 响应格式
|
||||
|
||||
框架使用统一的响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "操作成功",
|
||||
"data": {},
|
||||
"request_id": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 成功响应
|
||||
response.Success(c, data)
|
||||
response.SuccessWithMsg(c, "自定义消息", data)
|
||||
|
||||
// 失败响应
|
||||
response.Fail(c, "错误消息")
|
||||
|
||||
// 分页响应
|
||||
response.Page(c, list, total, page, pageSize)
|
||||
|
||||
// 错误响应
|
||||
response.Unauthorized(c, "请先登录")
|
||||
response.NotFound(c, "资源不存在")
|
||||
response.ServerError(c, "服务器错误")
|
||||
response.RateLimit(c)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI 工具
|
||||
|
||||
```bash
|
||||
# 创建新项目
|
||||
xlgo new myproject
|
||||
|
||||
# 创建新项目并指定模块路径
|
||||
xlgo new myproject --module github.com/myorg/myproject
|
||||
|
||||
# 生成代码
|
||||
xlgo make handler user # 创建 handler/user.go
|
||||
xlgo make repository user # 创建 repository/user_repository.go
|
||||
xlgo make model user # 创建 model/user.go
|
||||
xlgo make service user # 创建 service/user_service.go
|
||||
|
||||
# 显示版本
|
||||
xlgo version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
框架提供完整的测试工具包:
|
||||
|
||||
```go
|
||||
func TestUserAPI(t *testing.T) {
|
||||
router := test.SetupRouter()
|
||||
|
||||
// 创建请求
|
||||
resp := test.POST(router, "/api/v1/users").
|
||||
WithJSON(map[string]any{"name": "test"}).
|
||||
WithToken("xxx").
|
||||
Execute()
|
||||
|
||||
// 断言
|
||||
resp.AssertOK(t)
|
||||
|
||||
// 解析响应
|
||||
var result map[string]any
|
||||
resp.ParseJSON(&result)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
xlgo/
|
||||
├── app.go # 应用入口
|
||||
├── cache/
|
||||
│ ├── cache.go # 缓存服务
|
||||
│ ├── keybuilder.go # 键名前缀管理
|
||||
│ └── lock.go # 分布式锁
|
||||
├── cmd/
|
||||
│ └── xlgo/ # CLI 脚手架
|
||||
├── compress/
|
||||
│ └── compress.go # Gzip/Zip 压缩解压
|
||||
├── config/
|
||||
│ └── config.go # 配置管理(支持热更新)
|
||||
├── console/
|
||||
│ └── console.go # 彩色控制台输出
|
||||
├── cron/
|
||||
│ └── cron.go # 定时任务调度
|
||||
├── database/
|
||||
│ ├── mysql.go # MySQL 连接(支持读写分离)
|
||||
│ └── redis.go # Redis 连接
|
||||
├── handler/
|
||||
│ └── handler.go # 基础处理器(类型安全参数获取)
|
||||
├── jwt/
|
||||
│ └── jwt.go # JWT 工具
|
||||
├── logger/
|
||||
│ ├── logger.go # 日志实现
|
||||
│ └── field.go # 日志字段工具
|
||||
├── middleware/
|
||||
│ ├── auth.go # JWT 认证中间件
|
||||
│ ├── cors.go # CORS 跨域中间件
|
||||
│ ├── csrf.go # CSRF 防护中间件
|
||||
│ ├── logger.go # 请求日志中间件
|
||||
│ ├── ratelimit.go # 限流中间件
|
||||
│ ├── requestid.go # 请求ID中间件
|
||||
│ └── recover.go # Panic恢复中间件
|
||||
├── model/
|
||||
│ └── base.go # 基础模型
|
||||
├── repository/
|
||||
│ └── repository.go # 基础仓库(泛型CRUD)
|
||||
├── response/
|
||||
│ ├── response.go # 统一响应格式
|
||||
│ └── error.go # 统一错误码
|
||||
├── router/
|
||||
│ └── router.go # 路由注册中心(模块化/版本化)
|
||||
├── sse/
|
||||
│ └── sse.go # SSE 流式响应
|
||||
├── storage/
|
||||
│ └── storage.go # 文件存储(本地+OSS)
|
||||
├── test/
|
||||
│ └── test.go # 测试工具包
|
||||
├── trace/
|
||||
│ └── trace.go # 链路追踪
|
||||
├── utils/
|
||||
│ ├── random.go # 随机数生成
|
||||
│ ├── strings.go # 字符串处理
|
||||
│ ├── datetime.go # 时间日期
|
||||
│ ├── convert.go # 类型转换
|
||||
│ ├── file.go # 文件操作
|
||||
│ ├── url.go # URL处理
|
||||
│ ├── validator.go # 格式验证
|
||||
│ ├── crypto.go # 加密编码
|
||||
│ ├── http.go # HTTP客户端
|
||||
│ └── uuid.go # UUID生成
|
||||
├── validation/
|
||||
│ ├── validator.go # 请求验证器
|
||||
│ ├── password.go # 密码强度验证
|
||||
│ └── hash.go # 密码加密
|
||||
├── wire/
|
||||
│ └── wire.go # 依赖注入
|
||||
└── ws/
|
||||
└── ws.go # WebSocket 支持
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o server .
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
COPY --from=builder /app/config.yaml .
|
||||
RUN mkdir -p /app/public /app/logs
|
||||
EXPOSE 8080
|
||||
CMD ["./server", "-config", "./config.yaml"]
|
||||
```
|
||||
|
||||
```bash
|
||||
docker build -t xlgo-app:latest .
|
||||
docker run -d -p 8080:8080 xlgo-app:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.1.0 (2026-04-30)
|
||||
|
||||
- **分布式锁安全增强** - Lua 脚本 + UUID Token,只有持有者能释放锁
|
||||
- **JWT 黑名单优化** - 使用 JTI 替代完整 Token,大幅节省 Redis 内存
|
||||
- **HTTP Client 连接池** - Transport 初始化时创建,连接可复用
|
||||
- **优雅关闭机制** - 监听系统信号,等待请求处理完成
|
||||
- **Redis 分布式限流** - 滑动窗口算法,多实例共享限流状态
|
||||
- **Repository 扩展** - 分页查询、链式查询、批量操作、事务支持
|
||||
- **CORS 配置完善** - 支持配置文件、通配符域名
|
||||
- **日志中间件增强** - 可记录请求体、慢请求警告、敏感字段过滤
|
||||
- **新增测试** - cache、middleware 新增多项测试用例
|
||||
|
||||
### v2.0.0 (2026-04-30)
|
||||
|
||||
- **新增工具函数库** - 111 个实用函数(随机数、字符串、时间、转换、文件、URL、验证、加密、HTTP 客户端、UUID)
|
||||
- **新增彩色控制台输出** - console 包支持 Debug/Info/Success/Warn/Error 五级彩色输出
|
||||
- **新增压缩解压** - compress 包支持 Gzip/Zip 压缩解压
|
||||
- **新增键名前缀管理** - cache.K() 自动添加站点前缀,解决多项目共用 Redis 冲突
|
||||
- **新增分布式锁** - cache.Lock/TryLock/WithLock 完整实现
|
||||
- **新增计数器** - cache.Incr/Decr/IncrBy 支持
|
||||
- **新增 RequestID 中间件** - 请求追踪支持
|
||||
- **新增 Recover 中间件** - Panic 恢复
|
||||
- **新增类型安全参数获取** - handler.QueryInt/PathInt/FormInt 等
|
||||
- **新增路由架构** - router 包支持模块化、版本化 API、中间件分组、RESTful CRUD
|
||||
- **新增 AppConfig** - 站点别名、环境判断
|
||||
- **CLI 工具重构** - 模块化结构,模板分离
|
||||
- **单元测试覆盖** - 17 个包有测试(68%覆盖)
|
||||
|
||||
### v1.1.0 (2026-04-29)
|
||||
|
||||
- 新增配置热更新支持
|
||||
- 新增数据库读写分离
|
||||
- 新增 CSRF 防护中间件
|
||||
- 新增 SSE 流式响应支持
|
||||
- 新增 WebSocket 支持
|
||||
- 新增定时任务调度器
|
||||
- 新增 CLI 脚手架工具
|
||||
- 新增测试工具包
|
||||
- 新增统一错误码体系
|
||||
- 新增密码加密工具
|
||||
- 新增请求验证器(支持自定义错误消息)
|
||||
- 实现 OSS 存储上传
|
||||
- 优化缓存 DeleteByPattern 使用 SCAN
|
||||
- 修复限流器 goroutine 泄漏问题
|
||||
|
||||
### v1.0.0 (2024-04)
|
||||
|
||||
- 初始版本发布
|
||||
- 基础框架功能
|
||||
- 完整示例代码
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
@@ -0,0 +1,265 @@
|
||||
package xlgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/EthanCodeCraft/xlgo-core/storage"
|
||||
"github.com/EthanCodeCraft/xlgo-core/wire"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// App 应用实例
|
||||
type App struct {
|
||||
config *config.Config
|
||||
router *gin.Engine
|
||||
registry *router.Registry
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// Option 应用选项
|
||||
type Option func(*App)
|
||||
|
||||
// WithConfigPath 设置配置文件路径
|
||||
func WithConfigPath(path string) Option {
|
||||
return func(a *App) {
|
||||
_ = path // 配置在 New 时加载
|
||||
}
|
||||
}
|
||||
|
||||
// WithModules 注册模块
|
||||
func WithModules(modules ...router.Module) Option {
|
||||
return func(a *App) {
|
||||
for _, m := range modules {
|
||||
a.registry.RegisterModule(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithVersions 注册版本化 API
|
||||
func WithVersions(versions ...*router.VersionedAPI) Option {
|
||||
return func(a *App) {
|
||||
for _, v := range versions {
|
||||
a.registry.RegisterVersion(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiddlewares 注册全局中间件
|
||||
func WithMiddlewares(middlewares ...gin.HandlerFunc) Option {
|
||||
return func(a *App) {
|
||||
a.registry.Use(middlewares...)
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建新应用
|
||||
func New(opts ...Option) *App {
|
||||
app := &App{}
|
||||
app.router = gin.New()
|
||||
app.registry = router.NewRegistry(app.router)
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(app)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Run 启动应用
|
||||
func (a *App) Run() error {
|
||||
// 加载配置
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return config.ErrConfigNotLoaded
|
||||
}
|
||||
a.config = cfg
|
||||
|
||||
// 初始化日志
|
||||
if err := logger.Init(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitMySQL(cfg); err != nil {
|
||||
logger.Fatalf("初始化 MySQL 失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化 Redis
|
||||
if err := database.InitRedis(cfg); err != nil {
|
||||
logger.Fatalf("初始化 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
// 自动迁移数据库表
|
||||
if err := database.AutoMigrate(); err != nil {
|
||||
logger.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化存储
|
||||
if err := storage.Init(&cfg.Storage); err != nil {
|
||||
logger.Fatalf("初始化存储失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化服务容器
|
||||
wire.InitServices()
|
||||
|
||||
// 设置 Gin 模式
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 设置基础中间件
|
||||
a.router.Use(gin.Recovery())
|
||||
a.router.Use(middleware.Recover())
|
||||
|
||||
// 静态文件服务
|
||||
a.router.Static("/public", "./public")
|
||||
|
||||
// 注册默认路由(健康检查、Swagger)
|
||||
router.RegisterDefaultRoutes(a.router)
|
||||
|
||||
// 应用用户注册的路由
|
||||
a.registry.Apply()
|
||||
|
||||
// 启动服务器(优雅关闭)
|
||||
return a.StartServer()
|
||||
}
|
||||
|
||||
// StartServer 启动 HTTP 服务器(支持优雅关闭)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 监听系统信号,实现优雅关闭,等待请求处理完成
|
||||
func (a *App) StartServer() error {
|
||||
port := a.config.Server.Port
|
||||
|
||||
// 创建 HTTP 服务器
|
||||
a.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: a.router,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 启动服务器(非阻塞)
|
||||
go func() {
|
||||
logger.Infof("服务器启动,监听端口 %d", port)
|
||||
if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待中断信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
|
||||
// 阻塞等待信号
|
||||
sig := <-quit
|
||||
logger.Infof("收到信号 %v,开始优雅关闭...", sig)
|
||||
|
||||
// 执行关闭流程
|
||||
return a.Shutdown()
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭应用
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 按顺序关闭各组件,等待请求处理完成
|
||||
func (a *App) Shutdown() error {
|
||||
// 创建关闭上下文(最多等待 30 秒)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 停止 HTTP 服务器
|
||||
if a.server != nil {
|
||||
logger.Info("关闭 HTTP 服务器...")
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
logger.Warnf("HTTP 服务器关闭超时: %v", err)
|
||||
a.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止限流器
|
||||
logger.Info("停止限流器...")
|
||||
middleware.StopRateLimiters()
|
||||
|
||||
// 3. 关闭数据库连接
|
||||
logger.Info("关闭数据库连接...")
|
||||
database.Close()
|
||||
database.CloseRedis()
|
||||
|
||||
// 4. 同步日志
|
||||
logger.Info("同步日志...")
|
||||
logger.Sync()
|
||||
|
||||
logger.Info("应用已优雅关闭")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRegistry 获取路由注册中心(用于动态注册)
|
||||
func (a *App) GetRegistry() *router.Registry {
|
||||
return a.registry
|
||||
}
|
||||
|
||||
// GetRouter 获取 Gin Engine(用于高级自定义)
|
||||
func (a *App) GetRouter() *gin.Engine {
|
||||
return a.router
|
||||
}
|
||||
|
||||
// GetServer 获取 HTTP Server(用于高级自定义)
|
||||
func (a *App) GetServer() *http.Server {
|
||||
return a.server
|
||||
}
|
||||
|
||||
// StartServerWithPort 使用指定端口启动服务器(简化版本)
|
||||
// 注意: 此函数会阻塞,需要自行处理信号
|
||||
func StartServerWithPort(r *gin.Engine, port int) error {
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
logger.Infof("服务器启动,监听端口 %d", port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
// GracefulShutdown 优雅关闭辅助函数
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 可独立使用的优雅关闭函数
|
||||
func GracefulShutdown(server *http.Server, timeout time.Duration, cleanupFuncs ...func()) error {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-quit
|
||||
logger.Info("收到关闭信号,开始优雅关闭...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 关闭 HTTP 服务器
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Warnf("服务器关闭超时: %v", err)
|
||||
server.Close()
|
||||
}
|
||||
|
||||
// 执行清理函数
|
||||
for _, cleanup := range cleanupFuncs {
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("应用已优雅关闭")
|
||||
return nil
|
||||
}
|
||||
Vendored
+163
@@ -0,0 +1,163 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CacheService 缓存服务接口
|
||||
type CacheService interface {
|
||||
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
|
||||
Get(ctx context.Context, key string, dest any) bool
|
||||
// Set 设置缓存值
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
// Delete 删除缓存
|
||||
Delete(ctx context.Context, key string) error
|
||||
// DeleteByPattern 按模式删除缓存
|
||||
DeleteByPattern(ctx context.Context, pattern string) error
|
||||
// Exists 检查缓存是否存在
|
||||
Exists(ctx context.Context, key string) bool
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache 创建 Redis 缓存实例
|
||||
func NewRedisCache() CacheService {
|
||||
return &redisCache{
|
||||
client: database.RedisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
val, err := c.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(val), dest); err != nil {
|
||||
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
if c.client == nil {
|
||||
return nil // Redis 未启用,跳过缓存
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
logger.Warn("缓存序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||
logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis)
|
||||
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cursor uint64
|
||||
var deleted int
|
||||
|
||||
for {
|
||||
// 使用 SCAN 命令迭代查找匹配的键
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
|
||||
if err != nil {
|
||||
logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除找到的键
|
||||
if len(keys) > 0 {
|
||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
||||
logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
deleted += len(keys)
|
||||
}
|
||||
|
||||
// 更新游标
|
||||
cursor = nextCursor
|
||||
|
||||
// 游标为 0 表示遍历完成
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
logger.Debug("缓存批量删除完成", zap.String("pattern", pattern), zap.Int("count", deleted))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.client.Exists(ctx, key).Val() > 0
|
||||
}
|
||||
|
||||
// 全局缓存实例
|
||||
var globalCache CacheService
|
||||
|
||||
// Init 初始化全局缓存实例
|
||||
func Init() {
|
||||
globalCache = NewRedisCache()
|
||||
}
|
||||
|
||||
// GetCache 获取全局缓存实例
|
||||
func GetCache() CacheService {
|
||||
if globalCache == nil {
|
||||
Init()
|
||||
}
|
||||
return globalCache
|
||||
}
|
||||
Vendored
+287
@@ -0,0 +1,287 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// KeyBuilder 缓存键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多项目共用 Redis 时,通过前缀避免 key 冲突
|
||||
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
|
||||
type KeyBuilder struct {
|
||||
prefix string // 项目/站点前缀,如 "site_a"
|
||||
_separator string // 分隔符,默认 ":"
|
||||
_cacheType string // 缓存类型标识,如 "cache"
|
||||
}
|
||||
|
||||
// KeyBuilderOption 配置选项
|
||||
type KeyBuilderOption func(*KeyBuilder)
|
||||
|
||||
// WithPrefix 设置前缀(项目/站点别名)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 核心配置,用于区分不同项目
|
||||
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
|
||||
func WithPrefix(prefix string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb.prefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// WithSeparator 设置分隔符
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 可自定义分隔符,适应不同命名风格
|
||||
// 示例: WithSeparator(":") -> "site_a:user:1"
|
||||
func WithSeparator(separator string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb._separator = separator
|
||||
}
|
||||
}
|
||||
|
||||
// WithCacheType 设置缓存类型标识
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 区分缓存用途,便于管理
|
||||
// 示例: WithCacheType("session") -> "session_site_a_user:1"
|
||||
func WithCacheType(cacheType string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb._cacheType = cacheType
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyBuilder 创建键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式配置,灵活易用
|
||||
func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
kb := &KeyBuilder{
|
||||
prefix: "",
|
||||
_separator: ":",
|
||||
_cacheType: "cache",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(kb)
|
||||
}
|
||||
return kb
|
||||
}
|
||||
|
||||
// Build 构建完整键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动拼接前缀,避免手动拼接错误
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
parts := []string{}
|
||||
if kb._cacheType != "" {
|
||||
parts = append(parts, kb._cacheType)
|
||||
}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildTemp 构建临时缓存键名(带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 区分临时和永久缓存,便于批量管理
|
||||
// 格式: "temp{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
parts := []string{"temp"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPerm 构建永久缓存键名(不带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 永久存储的数据使用不同标识
|
||||
// 格式: "perm{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
parts := []string{"perm"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildLock 构建分布式锁键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 锁使用独立命名空间,避免与业务缓存冲突
|
||||
// 格式: "lock{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
parts := []string{"lock"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildCounter 构建计数器键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 计数器使用独立命名空间
|
||||
// 格式: "counter{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
parts := []string{"counter"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildSession 构建会话键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 会话缓存统一管理
|
||||
// 格式: "session{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
parts := []string{"session"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量查询/删除时使用
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
|
||||
// GetPrefix 获取当前前缀
|
||||
func (kb *KeyBuilder) GetPrefix() string {
|
||||
return kb.prefix
|
||||
}
|
||||
|
||||
// SetPrefix 动态设置前缀
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 运行时可切换前缀,适用于多租户场景
|
||||
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
|
||||
kb.prefix = prefix
|
||||
return kb
|
||||
}
|
||||
|
||||
// ===== 全局键名构建器 =====
|
||||
|
||||
var globalKeyBuilder *KeyBuilder
|
||||
|
||||
// InitKeyBuilder 初始化全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 应用启动时配置,全局统一命名
|
||||
// 参数: prefix 站点别名,如果为空则自动从配置读取
|
||||
// 示例:
|
||||
//
|
||||
// InitKeyBuilder("site_a") // 手动指定
|
||||
// InitKeyBuilder("") // 自动从配置读取
|
||||
// InitKeyBuilder("", WithSeparator(":")) // 自动读取 + 自定义分隔符
|
||||
func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) {
|
||||
// 如果 prefix 为空,尝试从配置读取
|
||||
if prefix == "" {
|
||||
cfg := config.Get()
|
||||
if cfg != nil {
|
||||
prefix = cfg.GetSiteName()
|
||||
}
|
||||
}
|
||||
|
||||
opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...)
|
||||
globalKeyBuilder = NewKeyBuilder(opts...)
|
||||
}
|
||||
|
||||
// AutoInitKeyBuilder 自动从配置初始化键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 无需手动传入参数,完全依赖配置文件
|
||||
// 配置示例:
|
||||
//
|
||||
// app:
|
||||
// site_name: "site_a"
|
||||
// env: "prod"
|
||||
func AutoInitKeyBuilder(opts ...KeyBuilderOption) {
|
||||
InitKeyBuilder("", opts...)
|
||||
}
|
||||
|
||||
// GetKeyBuilder 获取全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 如果未初始化,自动从配置创建
|
||||
func GetKeyBuilder() *KeyBuilder {
|
||||
if globalKeyBuilder == nil {
|
||||
// 自动从配置初始化
|
||||
AutoInitKeyBuilder()
|
||||
}
|
||||
return globalKeyBuilder
|
||||
}
|
||||
|
||||
// K 快捷构建键名(使用全局构建器)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 简短易用,减少代码量
|
||||
// 示例: cache.K("user:1") -> 自动添加前缀
|
||||
func K(key string) string {
|
||||
return GetKeyBuilder().Build(key)
|
||||
}
|
||||
|
||||
// KTemp 快捷构建临时缓存键名
|
||||
func KTemp(key string) string {
|
||||
return GetKeyBuilder().BuildTemp(key)
|
||||
}
|
||||
|
||||
// KPerm 快捷构建永久缓存键名
|
||||
func KPerm(key string) string {
|
||||
return GetKeyBuilder().BuildPerm(key)
|
||||
}
|
||||
|
||||
// KLock 快捷构建锁键名
|
||||
func KLock(key string) string {
|
||||
return GetKeyBuilder().BuildLock(key)
|
||||
}
|
||||
|
||||
// KCounter 快捷构建计数器键名
|
||||
func KCounter(key string) string {
|
||||
return GetKeyBuilder().BuildCounter(key)
|
||||
}
|
||||
|
||||
// KSession 快捷构建会话键名
|
||||
func KSession(key string) string {
|
||||
return GetKeyBuilder().BuildSession(key)
|
||||
}
|
||||
|
||||
// ===== 带键名构建器的缓存操作 =====
|
||||
|
||||
// SetWithPrefix 带前缀的缓存设置
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动添加前缀,简化调用
|
||||
func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Set(ctx, kb.Build(key), value, ttl)
|
||||
}
|
||||
|
||||
// GetWithPrefix 带前缀的缓存获取
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Get(ctx, kb.Build(key), dest)
|
||||
}
|
||||
|
||||
// DeleteWithPrefix 带前缀的缓存删除
|
||||
func DeleteWithPrefix(ctx context.Context, key string, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Delete(ctx, kb.Build(key))
|
||||
}
|
||||
|
||||
// LockWithPrefix 带前缀的分布式锁
|
||||
func LockWithPrefix(ctx context.Context, key string, ttl time.Duration, prefix string) (bool, error) {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return Lock(ctx, kb.BuildLock(key), ttl)
|
||||
}
|
||||
|
||||
// UnlockWithPrefix 带前缀的锁释放
|
||||
// 注意: 此函数不检查 Token,仅用于向后兼容,建议使用 NewLock/Unlock 组合
|
||||
func UnlockWithPrefix(ctx context.Context, key string, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return UnlockByKey(ctx, kb.BuildLock(key))
|
||||
}
|
||||
Vendored
+214
@@ -0,0 +1,214 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
)
|
||||
|
||||
func TestKeyBuilder(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("test_site"),
|
||||
cache.WithSeparator(":"),
|
||||
cache.WithCacheType("cache"),
|
||||
)
|
||||
|
||||
// 测试 Build
|
||||
result := kb.Build("user:1")
|
||||
expected := "cache:test_site:user:1"
|
||||
if result != expected {
|
||||
t.Errorf("Build = %s, want %s", result, expected)
|
||||
}
|
||||
|
||||
// 测试 BuildTemp
|
||||
tempResult := kb.BuildTemp("token")
|
||||
if tempResult != "temp:test_site:token" {
|
||||
t.Errorf("BuildTemp = %s, want temp:test_site:token", tempResult)
|
||||
}
|
||||
|
||||
// 测试 BuildPerm
|
||||
permResult := kb.BuildPerm("config")
|
||||
if permResult != "perm:test_site:config" {
|
||||
t.Errorf("BuildPerm = %s, want perm:test_site:config", permResult)
|
||||
}
|
||||
|
||||
// 测试 BuildLock
|
||||
lockResult := kb.BuildLock("order:123")
|
||||
if lockResult != "lock:test_site:order:123" {
|
||||
t.Errorf("BuildLock = %s, want lock:test_site:order:123", lockResult)
|
||||
}
|
||||
|
||||
// 测试 BuildCounter
|
||||
counterResult := kb.BuildCounter("visit")
|
||||
if counterResult != "counter:test_site:visit" {
|
||||
t.Errorf("BuildCounter = %s, want counter:test_site:visit", counterResult)
|
||||
}
|
||||
|
||||
// 测试 BuildSession
|
||||
sessionResult := kb.BuildSession("sid123")
|
||||
if sessionResult != "session:test_site:sid123" {
|
||||
t.Errorf("BuildSession = %s, want session:test_site:sid123", sessionResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderNoPrefix(t *testing.T) {
|
||||
// 无前缀的构建器
|
||||
kb := cache.NewKeyBuilder()
|
||||
|
||||
result := kb.Build("user:1")
|
||||
expected := "cache:user:1"
|
||||
if result != expected {
|
||||
t.Errorf("Build without prefix = %s, want %s", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderSetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
// 动态修改前缀
|
||||
kb.SetPrefix("site_b")
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "cache:site_b:user:1" {
|
||||
t.Errorf("SetPrefix failed: %s", result)
|
||||
}
|
||||
|
||||
// 验证链式调用
|
||||
kb2 := kb.SetPrefix("site_c")
|
||||
if kb2 != kb {
|
||||
t.Error("SetPrefix should return same builder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderGetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("my_site"))
|
||||
|
||||
prefix := kb.GetPrefix()
|
||||
if prefix != "my_site" {
|
||||
t.Errorf("GetPrefix = %s, want my_site", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderBuildPattern(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
result := kb.BuildPattern("user:*")
|
||||
if result != "cache:site_a:user:*" {
|
||||
t.Errorf("BuildPattern = %s, want cache:site_a:user:*", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderCustomSeparator(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("site"),
|
||||
cache.WithSeparator("_"),
|
||||
)
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "cache_site_user:1" {
|
||||
t.Errorf("Custom separator = %s, want cache_site_user:1", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderCustomCacheType(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("site"),
|
||||
cache.WithCacheType("session"),
|
||||
)
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "session:site:user:1" {
|
||||
t.Errorf("Custom cache type = %s, want session:site:user:1", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalKeyBuilder(t *testing.T) {
|
||||
// 初始化全局构建器
|
||||
cache.InitKeyBuilder("global_site")
|
||||
|
||||
// 测试 K 函数
|
||||
result := cache.K("user:1")
|
||||
if result != "cache:global_site:user:1" {
|
||||
t.Errorf("K = %s, want cache:global_site:user:1", result)
|
||||
}
|
||||
|
||||
// 测试 KTemp
|
||||
temp := cache.KTemp("token")
|
||||
if temp != "temp:global_site:token" {
|
||||
t.Errorf("KTemp = %s, want temp:global_site:token", temp)
|
||||
}
|
||||
|
||||
// 测试 KPerm
|
||||
perm := cache.KPerm("config")
|
||||
if perm != "perm:global_site:config" {
|
||||
t.Errorf("KPerm = %s, want perm:global_site:config", perm)
|
||||
}
|
||||
|
||||
// 测试 KLock
|
||||
lock := cache.KLock("order:123")
|
||||
if lock != "lock:global_site:order:123" {
|
||||
t.Errorf("KLock = %s, want lock:global_site:order:123", lock)
|
||||
}
|
||||
|
||||
// 测试 KCounter
|
||||
counter := cache.KCounter("visit")
|
||||
if counter != "counter:global_site:visit" {
|
||||
t.Errorf("KCounter = %s, want counter:global_site:visit", counter)
|
||||
}
|
||||
|
||||
// 测试 KSession
|
||||
session := cache.KSession("sid")
|
||||
if session != "session:global_site:sid" {
|
||||
t.Errorf("KSession = %s, want session:global_site:sid", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeyBuilder(t *testing.T) {
|
||||
// 获取全局构建器(如果未初始化会自动初始化)
|
||||
kb := cache.GetKeyBuilder()
|
||||
if kb == nil {
|
||||
t.Error("GetKeyBuilder should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithPrefixFunc(t *testing.T) {
|
||||
opt := cache.WithPrefix("test")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
if kb.GetPrefix() != "test" {
|
||||
t.Errorf("WithPrefix failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSeparatorFunc(t *testing.T) {
|
||||
opt := cache.WithSeparator("_")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
result := kb.Build("key")
|
||||
// 分隔符应该是 _
|
||||
if !contains(result, "_") {
|
||||
t.Errorf("WithSeparator failed, result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCacheTypeFunc(t *testing.T) {
|
||||
opt := cache.WithCacheType("custom")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
result := kb.Build("key")
|
||||
if !contains(result, "custom") {
|
||||
t.Errorf("WithCacheType failed, result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Vendored
+316
@@ -0,0 +1,316 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
)
|
||||
|
||||
// 分布式锁错误
|
||||
var (
|
||||
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
|
||||
ErrLockExpired = errors.New("锁已过期")
|
||||
ErrRedisNotReady = errors.New("Redis 未初始化")
|
||||
)
|
||||
|
||||
// LockToken 锁令牌(用于安全释放锁)
|
||||
type LockToken struct {
|
||||
Key string // 锁的键名
|
||||
Token string // 锁的唯一标识(UUID)
|
||||
}
|
||||
|
||||
// lockScript 加锁 Lua 脚本
|
||||
// 返回: 1 表示成功加锁,0 表示锁已被占用
|
||||
const lockScript = `
|
||||
if redis.call("exists", KEYS[1]) == 0 then
|
||||
redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// unlockScript 解锁 Lua 脯本
|
||||
// 只有持有正确 Token 的客户端才能解锁
|
||||
// 返回: 1 表示成功解锁,0 表示 Token 不匹配(锁不属于该客户端)
|
||||
const unlockScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
redis.call("del", KEYS[1])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// extendScript 续期 Lua 脚本
|
||||
// 只有持有正确 Token 的客户端才能续期
|
||||
// 返回: 1 表示成功续期,0 表示 Token 不匹配或锁不存在
|
||||
const extendScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
redis.call("pexpire", KEYS[1], ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// NewLock 创建分布式锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 UUID 作为 Token,保证锁的安全释放
|
||||
// 参数: key 锁名称,ttl 锁定时长
|
||||
// 返回: LockToken 用于后续解锁或续期
|
||||
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
|
||||
if database.RedisClient == nil {
|
||||
return nil, ErrRedisNotReady
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.(int64) == 1 {
|
||||
return &LockToken{Key: key, Token: token}, nil
|
||||
}
|
||||
|
||||
return nil, nil // 锁已被其他客户端持有
|
||||
}
|
||||
|
||||
// Lock 简化的加锁函数(返回 bool)
|
||||
// 注意: 使用此函数无法安全释放锁,建议使用 NewLock
|
||||
func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return token != nil, nil
|
||||
}
|
||||
|
||||
// Unlock 安全释放锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 Lua 脚本保证只有锁的持有者才能释放
|
||||
func Unlock(ctx context.Context, token *LockToken) error {
|
||||
if database.RedisClient == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
|
||||
// 注意: 此函数不检查 Token,任何客户端都能释放锁
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ExtendLock 续期锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时防止锁过期被其他客户端抢占
|
||||
// 参数: token 锁令牌,ttl 新的过期时间
|
||||
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TryLock 尝试获取锁,失败时等待重试
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 高并发场景常用,避免立即失败
|
||||
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// WithLock 使用分布式锁执行函数(自动管理锁)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动获取、续期、释放锁,避免忘记释放
|
||||
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
|
||||
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
}
|
||||
defer Unlock(ctx, token)
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时自动续期,防止锁过期
|
||||
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
|
||||
token, err := NewLock(ctx, key, initialTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
}
|
||||
|
||||
// 启动续期协程
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(extendInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 续期锁(每次续期为 initialTTL)
|
||||
if err := ExtendLock(ctx, token, initialTTL); err != nil {
|
||||
return // 续期失败,停止续期
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行业务函数
|
||||
err = fn()
|
||||
|
||||
// 停止续期并释放锁
|
||||
done <- struct{}{}
|
||||
Unlock(ctx, token)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsLocked 检查锁是否被占用(不获取锁)
|
||||
func IsLocked(ctx context.Context, key string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
|
||||
}
|
||||
|
||||
// GetLockTTL 获取锁的剩余过期时间
|
||||
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
|
||||
// 注意: 此函数不检查 Token,强制删除锁
|
||||
func ForceUnlock(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 计数器操作 =====
|
||||
|
||||
// Incr 自增计数器
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// IncrBy 指定增量自增
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
|
||||
// Decr 自减计数器
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTTL 获取键的剩余过期时间
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetExpire 设置键的过期时间
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
|
||||
// GetRaw 获取原始字符串值(不反序列化)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
if database.RedisClient == nil {
|
||||
return "", nil
|
||||
}
|
||||
return database.RedisClient.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetRaw 设置原始值(不序列化)
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
Vendored
+147
@@ -0,0 +1,147 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
)
|
||||
|
||||
func TestLockToken(t *testing.T) {
|
||||
token := &cache.LockToken{
|
||||
Key: "test_lock",
|
||||
Token: "abc123",
|
||||
}
|
||||
|
||||
if token.Key != "test_lock" {
|
||||
t.Error("LockToken Key failed")
|
||||
}
|
||||
if token.Token != "abc123" {
|
||||
t.Error("LockToken Token failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test ErrLockNotHeld - 当 Redis 未初始化时,先检查 nil token
|
||||
err := cache.Unlock(ctx, nil)
|
||||
// 当 Redis 未初始化时,会返回 ErrRedisNotReady
|
||||
if err != cache.ErrLockNotHeld && err != cache.ErrRedisNotReady {
|
||||
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test IsLocked without Redis (should return false)
|
||||
locked, err := cache.IsLocked(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("IsLocked error: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Error("IsLocked should return false without Redis")
|
||||
}
|
||||
|
||||
// Test GetLockTTL without Redis
|
||||
ttl, err := cache.GetLockTTL(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetLockTTL error: %v", err)
|
||||
}
|
||||
if ttl != 0 {
|
||||
t.Error("GetLockTTL should return 0 without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrDecr(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Incr without Redis
|
||||
n, err := cache.Incr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Incr error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Incr should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test IncrBy
|
||||
n, err = cache.IncrBy(ctx, "counter", 10)
|
||||
if err != nil {
|
||||
t.Errorf("IncrBy error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("IncrBy should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test Decr
|
||||
n, err = cache.Decr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Decr error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Decr should return 0 without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExpire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetExpire error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("SetExpire should return false without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRawSetRaw(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test SetRaw
|
||||
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetRaw error: %v", err)
|
||||
}
|
||||
|
||||
// Test GetRaw
|
||||
val, err := cache.GetRaw(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetRaw error: %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Error("GetRaw should return empty string without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKFunctions(t *testing.T) {
|
||||
// Test various K functions
|
||||
key := cache.K("user:1")
|
||||
if key == "" {
|
||||
t.Error("K should not return empty string")
|
||||
}
|
||||
|
||||
tempKey := cache.KTemp("token")
|
||||
if tempKey == "" {
|
||||
t.Error("KTemp should not return empty string")
|
||||
}
|
||||
|
||||
permKey := cache.KPerm("config")
|
||||
if permKey == "" {
|
||||
t.Error("KPerm should not return empty string")
|
||||
}
|
||||
|
||||
lockKey := cache.KLock("order:123")
|
||||
if lockKey == "" {
|
||||
t.Error("KLock should not return empty string")
|
||||
}
|
||||
|
||||
counterKey := cache.KCounter("visit")
|
||||
if counterKey == "" {
|
||||
t.Error("KCounter should not return empty string")
|
||||
}
|
||||
|
||||
sessionKey := cache.KSession("sid")
|
||||
if sessionKey == "" {
|
||||
t.Error("KSession should not return empty string")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func createProject(name string) {
|
||||
if _, err := os.Stat(name); !os.IsNotExist(err) {
|
||||
fmt.Printf("目录 %s 已存在\n", name)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建目录结构
|
||||
dirs := []string{
|
||||
name,
|
||||
name + "/config",
|
||||
name + "/handler",
|
||||
name + "/model",
|
||||
name + "/repository",
|
||||
name + "/service",
|
||||
name + "/middleware",
|
||||
name + "/public",
|
||||
name + "/logs",
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模块路径
|
||||
module := name
|
||||
if len(os.Args) > 3 && os.Args[3] == "--module" && len(os.Args) > 4 {
|
||||
module = os.Args[4]
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
data := TemplateData{
|
||||
Package: caser.String(name),
|
||||
Name: caser.String(name),
|
||||
NameLower: strings.ToLower(name),
|
||||
Module: module,
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
files := map[string]string{
|
||||
name + "/main.go": templates.Main,
|
||||
name + "/config.yaml": templates.Config,
|
||||
name + "/go.mod": fmt.Sprintf(templates.GoMod, module),
|
||||
name + "/Makefile": templates.Makefile,
|
||||
name + "/.gitignore": templates.Gitignore,
|
||||
name + "/handler/home.go": templates.Handler,
|
||||
}
|
||||
|
||||
for path, content := range files {
|
||||
tmpl, err := template.New(path).Parse(content)
|
||||
if err != nil {
|
||||
fmt.Printf("解析模板失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
fmt.Printf("创建文件失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := tmpl.Execute(file, data); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✓ 项目 %s 创建成功\n", name)
|
||||
fmt.Println("\n下一步:")
|
||||
fmt.Printf(" cd %s\n", name)
|
||||
fmt.Println(" go mod tidy")
|
||||
fmt.Println(" go run main.go")
|
||||
}
|
||||
|
||||
func makeFile(fileType, name string) {
|
||||
name = strings.ToLower(name)
|
||||
caser := cases.Title(language.English)
|
||||
nameTitle := caser.String(name)
|
||||
|
||||
switch fileType {
|
||||
case "handler":
|
||||
createHandler(name, nameTitle)
|
||||
case "repository":
|
||||
createRepository(name, nameTitle)
|
||||
case "model":
|
||||
createModel(name, nameTitle)
|
||||
case "service":
|
||||
createService(name, nameTitle)
|
||||
default:
|
||||
fmt.Printf("未知类型: %s\n", fileType)
|
||||
fmt.Println("可用类型: handler, repository, model, service")
|
||||
}
|
||||
}
|
||||
|
||||
func createHandler(name, nameTitle string) {
|
||||
path := fmt.Sprintf("handler/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.HandlerMake,
|
||||
nameTitle, name, nameTitle,
|
||||
nameTitle, name, nameTitle, nameTitle, nameTitle,
|
||||
name, nameTitle, name, nameTitle,
|
||||
nameTitle, name, name, nameTitle,
|
||||
nameTitle, name, name, nameTitle,
|
||||
nameTitle, name, name, nameTitle,
|
||||
nameTitle, name, name, nameTitle,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
fmt.Printf("✓ 创建处理器: %s\n", path)
|
||||
}
|
||||
|
||||
func createRepository(name, nameTitle string) {
|
||||
path := fmt.Sprintf("repository/%s_repository.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.RepositoryMake,
|
||||
nameTitle, name, nameTitle, nameTitle,
|
||||
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle, nameTitle,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
fmt.Printf("✓ 创建仓库: %s\n", path)
|
||||
}
|
||||
|
||||
func createModel(name, nameTitle string) {
|
||||
path := fmt.Sprintf("model/%s.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ModelMake,
|
||||
nameTitle, name, nameTitle, nameTitle, name,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
fmt.Printf("✓ 创建模型: %s\n", path)
|
||||
}
|
||||
|
||||
func createService(name, nameTitle string) {
|
||||
path := fmt.Sprintf("service/%s_service.go", name)
|
||||
if fileExists(path) {
|
||||
fmt.Printf("文件 %s 已存在\n", path)
|
||||
return
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(templates.ServiceMake,
|
||||
nameTitle, name, nameTitle, nameTitle,
|
||||
nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
nameTitle, nameTitle,
|
||||
)
|
||||
|
||||
writeFile(path, content)
|
||||
fmt.Printf("✓ 创建服务: %s\n", path)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(`xlgo - Go Web 框架脚手架工具
|
||||
|
||||
用法:
|
||||
xlgo new <项目名> 创建新项目
|
||||
xlgo make handler <名称> 创建处理器
|
||||
xlgo make repository <名称> 创建仓库
|
||||
xlgo make model <名称> 创建模型
|
||||
xlgo make service <名称> 创建服务
|
||||
xlgo version 显示版本号
|
||||
|
||||
示例:
|
||||
xlgo new myapp
|
||||
xlgo make handler user
|
||||
xlgo make repository user`)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
switch command {
|
||||
case "new":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("用法: xlgo new <项目名>")
|
||||
return
|
||||
}
|
||||
createProject(os.Args[2])
|
||||
|
||||
case "make":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("用法: xlgo make <类型> <名称>")
|
||||
fmt.Println("类型: handler, repository, model, service")
|
||||
return
|
||||
}
|
||||
makeFile(os.Args[2], os.Args[3])
|
||||
|
||||
case "version":
|
||||
fmt.Println("xlgo v1.0.0")
|
||||
|
||||
default:
|
||||
printUsage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package main
|
||||
|
||||
// Templates 存放所有代码生成模板
|
||||
var templates = struct {
|
||||
Main string
|
||||
Config string
|
||||
GoMod string
|
||||
Makefile string
|
||||
Gitignore string
|
||||
Handler string
|
||||
HandlerMake string
|
||||
|
||||
RepositoryMake string
|
||||
ModelMake string
|
||||
ServiceMake string
|
||||
}{
|
||||
// Main 新项目主文件模板
|
||||
Main: `package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
"{{.Module}}/config"
|
||||
"{{.Module}}/database"
|
||||
"{{.Module}}/handler"
|
||||
"{{.Module}}/logger"
|
||||
"{{.Module}}/middleware"
|
||||
"{{.Module}}/storage"
|
||||
"{{.Module}}/validation"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&configPath, "config", "./config.yaml", "配置文件路径")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if err := logger.Init(cfg); err != nil {
|
||||
fmt.Printf("初始化日志失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.InitMySQL(cfg); err != nil {
|
||||
logger.Fatalf("初始化 MySQL 失败: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 初始化 Redis
|
||||
if err := database.InitRedis(cfg); err != nil {
|
||||
logger.Fatalf("初始化 Redis 失败: %v", err)
|
||||
}
|
||||
defer database.CloseRedis()
|
||||
|
||||
// 初始化存储
|
||||
if err := storage.Init(&cfg.Storage); err != nil {
|
||||
logger.Fatalf("初始化存储失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化验证器
|
||||
validation.InitValidator()
|
||||
|
||||
// 设置 Gin 模式
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建路由
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.Logger())
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 静态文件服务
|
||||
r.Static("/public", "./public")
|
||||
|
||||
// 注册路由
|
||||
setupRoutes(r)
|
||||
|
||||
// 启动服务器
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
go func() {
|
||||
logger.Infof("服务器启动,监听端口 %d", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("服务器启动失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待中断信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
logger.Info("正在关闭服务器...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
logger.Fatalf("服务器强制关闭: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("服务器已关闭")
|
||||
}
|
||||
|
||||
func setupRoutes(r *gin.Engine) {
|
||||
// 初始化限速器
|
||||
middleware.InitRateLimiters()
|
||||
|
||||
// Swagger 文档
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", handler.HealthCheck)
|
||||
|
||||
// API 路由
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// 首页
|
||||
api.GET("/", handler.Home)
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
// Config 配置文件模板
|
||||
Config: `app:
|
||||
name: "{{.Name}}"
|
||||
site_name: "{{.NameLower}}" # 站点别名,用于缓存键前缀、日志标识、多站点区分
|
||||
version: "1.0.0"
|
||||
env: "dev" # dev/test/prod
|
||||
debug: true
|
||||
base_url: "http://localhost:8080"
|
||||
token_expire: 86400 # Token过期时间(秒)
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
mode: development
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 3306
|
||||
user: root
|
||||
password: your_password
|
||||
name: {{.NameLower}}
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: your_jwt_secret_key_here
|
||||
expire: 86400
|
||||
|
||||
storage:
|
||||
driver: local
|
||||
local:
|
||||
path: ./public
|
||||
base_url: http://localhost:8080/public
|
||||
|
||||
log:
|
||||
dir: ./logs
|
||||
max_size: 100
|
||||
max_backups: 30
|
||||
max_age: 30
|
||||
compress: true
|
||||
`,
|
||||
|
||||
// GoMod go.mod 文件模板
|
||||
GoMod: `module %s
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/redis/go-redis/v9 v9.5.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.3
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/mysql v1.5.4
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
`,
|
||||
|
||||
// Makefile 模板
|
||||
Makefile: `.PHONY: build run test clean tidy swagger
|
||||
|
||||
build:
|
||||
go build -o bin/server .
|
||||
|
||||
run:
|
||||
go run main.go
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -rf logs/
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
swagger:
|
||||
swag init -g main.go -o ./swagger
|
||||
`,
|
||||
|
||||
// Gitignore 模板
|
||||
Gitignore: `# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test files
|
||||
*.test
|
||||
*.out
|
||||
coverage.txt
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Config (keep example)
|
||||
config.local.yaml
|
||||
`,
|
||||
|
||||
// Handler 新项目默认处理器模板
|
||||
Handler: `package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"{{.Module}}/response"
|
||||
)
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func HealthCheck(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// Home 首页
|
||||
func Home(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"message": "Welcome to {{.Name}}!",
|
||||
})
|
||||
}
|
||||
`,
|
||||
|
||||
// HandlerMake make handler 命令模板
|
||||
HandlerMake: `package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"xlgo/response"
|
||||
)
|
||||
|
||||
// %sHandler %s 处理器
|
||||
type %sHandler struct {
|
||||
// 可以注入 service
|
||||
}
|
||||
|
||||
// New%sHandler 创建 %s 处理器
|
||||
func New%sHandler() *%sHandler {
|
||||
return &%sHandler{}
|
||||
}
|
||||
|
||||
// List 获取列表
|
||||
// @Summary 获取%s列表
|
||||
// @Tags %s
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/%ss [get]
|
||||
func (h *%sHandler) List(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"Items": []string{},
|
||||
})
|
||||
}
|
||||
|
||||
// Get 获取详情
|
||||
// @Summary 获取%s详情
|
||||
// @Tags %s
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/%ss/{id} [get]
|
||||
func (h *%sHandler) Get(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"id": c.Param("id"),
|
||||
})
|
||||
}
|
||||
|
||||
// Create 创建
|
||||
// @Summary 创建%s
|
||||
// @Tags %s
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/%ss [post]
|
||||
func (h *%sHandler) Create(c *gin.Context) {
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
// Update 更新
|
||||
// @Summary 更新%s
|
||||
// @Tags %s
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/%ss/{id} [put]
|
||||
func (h *%sHandler) Update(c *gin.Context) {
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
// Delete 删除
|
||||
// @Summary 删除%s
|
||||
// @Tags %s
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /api/v1/%ss/{id} [delete]
|
||||
func (h *%sHandler) Delete(c *gin.Context) {
|
||||
response.Success(c, nil)
|
||||
}
|
||||
`,
|
||||
|
||||
// RepositoryMake make repository 命令模板
|
||||
RepositoryMake: `package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"xlgo/database"
|
||||
"xlgo/model"
|
||||
)
|
||||
|
||||
// %sRepository %s 仓库
|
||||
type %sRepository struct {
|
||||
*BaseRepo[model.%s]
|
||||
}
|
||||
|
||||
// New%sRepository 创建 %s 仓库
|
||||
func New%sRepository() *%sRepository {
|
||||
return &%sRepository{
|
||||
BaseRepo: NewBaseRepo[model.%s](database.GetDB()),
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName 根据名称查询
|
||||
func (r *%sRepository) FindByName(ctx context.Context, name string) (*model.%s, error) {
|
||||
var m model.%s
|
||||
err := r.GetDB().WithContext(ctx).Where("name = ?", name).First(&m).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
`,
|
||||
|
||||
// ModelMake make model 命令模板
|
||||
ModelMake: `package model
|
||||
|
||||
// %s %s 模型
|
||||
type %s struct {
|
||||
BaseModel
|
||||
Name string ` + "`" + `gorm:"size:100;not null" json:"name"` + "`" + `
|
||||
Description string ` + "`" + `gorm:"size:500" json:"description"` + "`" + `
|
||||
Status int ` + "`" + `gorm:"default:1" json:"status"` + "`" + ` // 1: 启用, 0: 禁用
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (%s) TableName() string {
|
||||
return "%ss"
|
||||
}
|
||||
`,
|
||||
|
||||
// ServiceMake make service 命令模板
|
||||
ServiceMake: `package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"xlgo/model"
|
||||
"xlgo/repository"
|
||||
)
|
||||
|
||||
// %sService %s 服务
|
||||
type %sService struct {
|
||||
repo *repository.%sRepository
|
||||
}
|
||||
|
||||
// New%sService 创建 %s 服务
|
||||
func New%sService() *%sService {
|
||||
return &%sService{
|
||||
repo: repository.New%sRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取列表
|
||||
func (s *%sService) List(ctx context.Context, page, pageSize int) ([]model.%s, int64, error) {
|
||||
// TODO: 实现列表查询
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取
|
||||
func (s *%sService) GetByID(ctx context.Context, id uint) (*model.%s, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// Create 创建
|
||||
func (s *%sService) Create(ctx context.Context, m *model.%s) error {
|
||||
return s.repo.Create(ctx, m)
|
||||
}
|
||||
|
||||
// Update 更新
|
||||
func (s *%sService) Update(ctx context.Context, m *model.%s) error {
|
||||
return s.repo.Update(ctx, m)
|
||||
}
|
||||
|
||||
// Delete 删除
|
||||
func (s *%sService) Delete(ctx context.Context, id uint) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
`,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
// TemplateData 模板数据
|
||||
type TemplateData struct {
|
||||
Package string
|
||||
Name string
|
||||
NameLower string
|
||||
Module string
|
||||
Year int
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func writeFile(path, content string) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
fmt.Printf("创建目录失败: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
fmt.Printf("写入文件失败: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package compress
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GzipCompress 压缩数据
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: API响应压缩、数据传输常用
|
||||
func GzipCompress(data []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(data); err != nil {
|
||||
_ = gz.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// GzipDecompress 解压缩数据
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: GzipCompress 的反向操作
|
||||
func GzipDecompress(data []byte) ([]byte, error) {
|
||||
buf := bytes.NewReader(data)
|
||||
gz, err := gzip.NewReader(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
return io.ReadAll(gz)
|
||||
}
|
||||
|
||||
// GzipCompressFile 压缩文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 日志归档、文件传输常用
|
||||
func GzipCompressFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
gz := gzip.NewWriter(dstFile)
|
||||
defer gz.Close()
|
||||
|
||||
// 保留原文件名和时间戳
|
||||
if info, err := srcFile.Stat(); err == nil {
|
||||
gz.Name = info.Name()
|
||||
gz.ModTime = info.ModTime()
|
||||
}
|
||||
|
||||
_, err = io.Copy(gz, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// GzipDecompressFile 解压文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: GzipCompressFile 的反向操作
|
||||
func GzipDecompressFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
gz, err := gzip.NewReader(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, gz)
|
||||
return err
|
||||
}
|
||||
|
||||
// Zip 压缩文件或目录
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量打包下载、备份常用
|
||||
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
|
||||
func Zip(zipPath string, paths []string) error {
|
||||
// 创建目标目录
|
||||
if err := os.MkdirAll(filepath.Dir(zipPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 zip 文件
|
||||
archive, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(archive)
|
||||
defer zipWriter.Close()
|
||||
|
||||
for _, srcPath := range paths {
|
||||
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
|
||||
|
||||
err = filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建文件头
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Method = zip.Deflate
|
||||
|
||||
// 设置相对路径
|
||||
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
header.Name += string(os.PathSeparator)
|
||||
}
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(writer, file)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unzip 解压 zip 文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: Zip 的反向操作
|
||||
// 参数: zipPath zip文件路径,dstDir 目标目录
|
||||
func Unzip(zipPath, dstDir string) error {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
if err := unzipFile(file, dstDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipFile(file *zip.File, dstDir string) error {
|
||||
filePath := path.Join(dstDir, file.Name)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
return os.MkdirAll(filePath, 0755)
|
||||
}
|
||||
|
||||
// 创建父目录
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 打开 zip 中的文件
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, rc)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package compress_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/compress"
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
)
|
||||
|
||||
func fileExists(path string) bool {
|
||||
return utils.FileExists(path)
|
||||
}
|
||||
|
||||
func getTempDir() string {
|
||||
return filepath.Join(os.TempDir(), "xlgo_compress_test")
|
||||
}
|
||||
|
||||
func setupTestFiles(t *testing.T) (string, string, string) {
|
||||
dir := getTempDir()
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
// 创建测试文件
|
||||
srcFile := filepath.Join(dir, "test.txt")
|
||||
content := "Hello, this is a test file for compression!"
|
||||
os.WriteFile(srcFile, []byte(content), 0644)
|
||||
|
||||
// 创建子目录和文件
|
||||
subDir := filepath.Join(dir, "subdir")
|
||||
os.MkdirAll(subDir, 0755)
|
||||
subFile := filepath.Join(subDir, "sub.txt")
|
||||
os.WriteFile(subFile, []byte("Subdir content"), 0644)
|
||||
|
||||
return dir, srcFile, content
|
||||
}
|
||||
|
||||
func cleanupTestDir(t *testing.T) {
|
||||
os.RemoveAll(getTempDir())
|
||||
}
|
||||
|
||||
func TestGzipCompress(t *testing.T) {
|
||||
defer cleanupTestDir(t)
|
||||
|
||||
data := []byte("Hello World, this is test data for gzip compression!")
|
||||
compressed, err := compress.GzipCompress(data)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipCompress error: %v", err)
|
||||
}
|
||||
|
||||
if len(compressed) == 0 {
|
||||
t.Error("Compressed data is empty")
|
||||
}
|
||||
|
||||
// 压缩后的数据应该比原数据小(对于重复数据)
|
||||
// 但对于很短的数据可能更大,所以不做严格长度比较
|
||||
|
||||
// 解压缩验证
|
||||
decompressed, err := compress.GzipDecompress(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipDecompress error: %v", err)
|
||||
}
|
||||
|
||||
if string(decompressed) != string(data) {
|
||||
t.Errorf("Decompressed data mismatch: got %s, want %s", decompressed, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipDecompressInvalid(t *testing.T) {
|
||||
// 测试无效数据解压
|
||||
_, err := compress.GzipDecompress([]byte("invalid gzip data"))
|
||||
if err == nil {
|
||||
t.Error("GzipDecompress should fail with invalid data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipCompressEmpty(t *testing.T) {
|
||||
data := []byte("")
|
||||
compressed, err := compress.GzipCompress(data)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipCompress empty error: %v", err)
|
||||
}
|
||||
|
||||
decompressed, err := compress.GzipDecompress(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipDecompress empty error: %v", err)
|
||||
}
|
||||
|
||||
if len(decompressed) != 0 {
|
||||
t.Error("Decompressed empty data should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipCompressFile(t *testing.T) {
|
||||
defer cleanupTestDir(t)
|
||||
dir, srcFile, content := setupTestFiles(t)
|
||||
|
||||
dstFile := filepath.Join(dir, "test.txt.gz")
|
||||
|
||||
// 压缩文件
|
||||
err := compress.GzipCompressFile(srcFile, dstFile)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipCompressFile error: %v", err)
|
||||
}
|
||||
|
||||
// 验证压缩文件存在
|
||||
if !fileExists(dstFile) {
|
||||
t.Error("Compressed file not created")
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
outFile := filepath.Join(dir, "test_out.txt")
|
||||
err = compress.GzipDecompressFile(dstFile, outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("GzipDecompressFile error: %v", err)
|
||||
}
|
||||
|
||||
// 验证解压内容
|
||||
outContent, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error: %v", err)
|
||||
}
|
||||
|
||||
if string(outContent) != content {
|
||||
t.Errorf("Decompressed content mismatch: got %s, want %s", outContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipCompressFileNonexistent(t *testing.T) {
|
||||
err := compress.GzipCompressFile("/nonexistent/file.txt", "/tmp/out.gz")
|
||||
if err == nil {
|
||||
t.Error("GzipCompressFile should fail with nonexistent source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZip(t *testing.T) {
|
||||
defer cleanupTestDir(t)
|
||||
dir, srcFile, _ := setupTestFiles(t)
|
||||
|
||||
zipPath := filepath.Join(dir, "test.zip")
|
||||
paths := []string{srcFile, filepath.Join(dir, "subdir")}
|
||||
|
||||
// 创建 zip
|
||||
err := compress.Zip(zipPath, paths)
|
||||
if err != nil {
|
||||
t.Fatalf("Zip error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 zip 文件存在
|
||||
if !fileExists(zipPath) {
|
||||
t.Error("Zip file not created")
|
||||
}
|
||||
|
||||
// 解压 zip
|
||||
dstDir := filepath.Join(dir, "unzipped")
|
||||
err = compress.Unzip(zipPath, dstDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Unzip error: %v", err)
|
||||
}
|
||||
|
||||
// 验证解压后的文件
|
||||
outFile := filepath.Join(dstDir, "test.txt")
|
||||
if !fileExists(outFile) {
|
||||
t.Error("Unzipped file not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZipSingleFile(t *testing.T) {
|
||||
defer cleanupTestDir(t)
|
||||
dir, srcFile, content := setupTestFiles(t)
|
||||
|
||||
zipPath := filepath.Join(dir, "single.zip")
|
||||
|
||||
err := compress.Zip(zipPath, []string{srcFile})
|
||||
if err != nil {
|
||||
t.Fatalf("Zip single file error: %v", err)
|
||||
}
|
||||
|
||||
dstDir := filepath.Join(dir, "single_unzipped")
|
||||
err = compress.Unzip(zipPath, dstDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Unzip error: %v", err)
|
||||
}
|
||||
|
||||
// 验证内容
|
||||
outContent, err := os.ReadFile(filepath.Join(dstDir, "test.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error: %v", err)
|
||||
}
|
||||
|
||||
if string(outContent) != content {
|
||||
t.Error("Unzipped content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnzipInvalid(t *testing.T) {
|
||||
defer cleanupTestDir(t)
|
||||
dir := getTempDir()
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
// 无效 zip 文件
|
||||
invalidZip := filepath.Join(dir, "invalid.zip")
|
||||
os.WriteFile(invalidZip, []byte("not a zip file"), 0644)
|
||||
|
||||
dstDir := filepath.Join(dir, "out")
|
||||
err := compress.Unzip(invalidZip, dstDir)
|
||||
if err == nil {
|
||||
t.Error("Unzip should fail with invalid zip file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnzipNonexistent(t *testing.T) {
|
||||
err := compress.Unzip("/nonexistent/file.zip", "/tmp/out")
|
||||
if err == nil {
|
||||
t.Error("Unzip should fail with nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Benchmarks =====
|
||||
|
||||
func BenchmarkGzipCompress(b *testing.B) {
|
||||
data := make([]byte, 1024)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
compress.GzipCompress(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGzipDecompress(b *testing.B) {
|
||||
data := make([]byte, 1024)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
compressed, _ := compress.GzipCompress(data)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
compress.GzipDecompress(compressed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// 配置错误
|
||||
var (
|
||||
ErrConfigNotLoaded = fmt.Errorf("配置未加载")
|
||||
)
|
||||
|
||||
// Config 全局配置结构体
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
SMS SMSConfig `mapstructure:"sms"`
|
||||
Storage StorageConfig `mapstructure:"storage"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
}
|
||||
|
||||
// AppConfig 应用配置
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多站点共用 Redis/日志时,通过 SiteName 区分来源
|
||||
// 使用场景:
|
||||
// - 缓存键名前缀: cache:{site_name}:user:1
|
||||
// - 日志标识: [site_a] 2024-01-01 10:00:00 ...
|
||||
// - 站点追踪: Request-ID 带站点标识
|
||||
// - 分布式锁: lock:{site_name}:order:123
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name"` // 应用名称,如 "用户管理系统"
|
||||
SiteName string `mapstructure:"site_name"` // 站点别名,如 "site_a"、"user_api"
|
||||
Version string `mapstructure:"version"` // 应用版本
|
||||
Env string `mapstructure:"env"` // 运行环境: dev/test/prod
|
||||
Debug bool `mapstructure:"debug"` // 是否开启调试模式
|
||||
BaseURL string `mapstructure:"base_url"` // 应用基础URL
|
||||
TokenExpire int `mapstructure:"token_expire"` // Token过期时间(秒)
|
||||
}
|
||||
|
||||
// GetSiteName 获取站点别名,如果未设置则返回空字符串
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 安全获取,避免空指针
|
||||
func (c *AppConfig) GetSiteName() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.SiteName
|
||||
}
|
||||
|
||||
// GetCachePrefix 获取缓存键名前缀
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 统一的缓存前缀生成方法
|
||||
func (c *AppConfig) GetCachePrefix() string {
|
||||
return c.GetSiteName()
|
||||
}
|
||||
|
||||
// IsDebug 是否调试模式
|
||||
func (c *AppConfig) IsDebug() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.Debug
|
||||
}
|
||||
|
||||
// IsDev 是否开发环境
|
||||
func (c *AppConfig) IsDev() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.Env == "dev" || c.Env == "development"
|
||||
}
|
||||
|
||||
// IsProd 是否生产环境
|
||||
func (c *AppConfig) IsProd() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
return c.Env == "prod" || c.Env == "production"
|
||||
}
|
||||
|
||||
// ServerConfig 服务配置
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // development 或 production
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Name string `mapstructure:"name"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
}
|
||||
|
||||
// DSN 返回 MySQL 连接字符串
|
||||
func (c *DatabaseConfig) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
c.User, c.Password, c.Host, c.Port, c.Name)
|
||||
}
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
type RedisConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Password string `mapstructure:"password"`
|
||||
DB int `mapstructure:"db"`
|
||||
}
|
||||
|
||||
// Addr 返回 Redis 地址
|
||||
func (c *RedisConfig) Addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
// JWTConfig JWT 配置
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
Expire int `mapstructure:"expire"` // 过期时间(秒)
|
||||
}
|
||||
|
||||
// SMSConfig 短信配置
|
||||
type SMSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
AccessKeyID string `mapstructure:"access_key_id"`
|
||||
AccessKeySecret string `mapstructure:"access_key_secret"`
|
||||
SignName string `mapstructure:"sign_name"`
|
||||
TemplateCode string `mapstructure:"template_code"`
|
||||
}
|
||||
|
||||
// StorageConfig 文件存储配置
|
||||
type StorageConfig struct {
|
||||
Driver string `mapstructure:"driver"` // local 或 oss
|
||||
Local LocalStorageConfig `mapstructure:"local"`
|
||||
OSS OSSStorageConfig `mapstructure:"oss"`
|
||||
}
|
||||
|
||||
// LocalStorageConfig 本地存储配置
|
||||
type LocalStorageConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
}
|
||||
|
||||
// OSSStorageConfig OSS 存储配置
|
||||
type OSSStorageConfig struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
AccessKeyID string `mapstructure:"access_key_id"`
|
||||
AccessKeySecret string `mapstructure:"access_key_secret"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
Dir string `mapstructure:"dir"`
|
||||
MaxSize int `mapstructure:"max_size"` // MB
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
MaxAge int `mapstructure:"max_age"` // 天
|
||||
Compress bool `mapstructure:"compress"`
|
||||
}
|
||||
|
||||
// UploadConfig 上传配置
|
||||
type UploadConfig struct {
|
||||
MaxFileSize int `mapstructure:"max_file_size"` // 最大图片大小(MB)
|
||||
MaxVideoSize int `mapstructure:"max_video_size"` // 最大视频大小(MB)
|
||||
MaxAvatarSize int `mapstructure:"max_avatar_size"` // 最大头像大小(MB)
|
||||
AllowedImageTypes []string `mapstructure:"allowed_image_types"` // 允许的图片 MIME 类型
|
||||
AllowedVideoTypes []string `mapstructure:"allowed_video_types"` // 允许的视频 MIME 类型
|
||||
}
|
||||
|
||||
// CORSConfig CORS 跨域配置
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `mapstructure:"allowed_origins"` // 允许的域名列表
|
||||
AllowedMethods []string `mapstructure:"allowed_methods"` // 允许的方法
|
||||
AllowedHeaders []string `mapstructure:"allowed_headers"` // 允许的请求头
|
||||
ExposedHeaders []string `mapstructure:"exposed_headers"` // 暴露的响应头
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"` // 是否允许携带凭证
|
||||
MaxAge int `mapstructure:"max_age"` // 预检请求缓存时间(秒)
|
||||
}
|
||||
|
||||
// GetAllowedOrigins 获取允许的域名列表
|
||||
func (c *CORSConfig) GetAllowedOrigins() []string {
|
||||
if c == nil || len(c.AllowedOrigins) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return c.AllowedOrigins
|
||||
}
|
||||
|
||||
// GetAllowedMethods 获取允许的方法列表
|
||||
func (c *CORSConfig) GetAllowedMethods() []string {
|
||||
if c == nil || len(c.AllowedMethods) == 0 {
|
||||
return []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
}
|
||||
return c.AllowedMethods
|
||||
}
|
||||
|
||||
// GetAllowedHeaders 获取允许的请求头列表
|
||||
func (c *CORSConfig) GetAllowedHeaders() []string {
|
||||
if c == nil || len(c.AllowedHeaders) == 0 {
|
||||
return []string{"Origin", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "X-Requested-With"}
|
||||
}
|
||||
return c.AllowedHeaders
|
||||
}
|
||||
|
||||
// GetExposedHeaders 获取暴露的响应头列表
|
||||
func (c *CORSConfig) GetExposedHeaders() []string {
|
||||
if c == nil || len(c.ExposedHeaders) == 0 {
|
||||
return []string{"Content-Length", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Content-Type"}
|
||||
}
|
||||
return c.ExposedHeaders
|
||||
}
|
||||
|
||||
// GetMaxAge 获取预检请求缓存时间
|
||||
func (c *CORSConfig) GetMaxAge() int {
|
||||
if c == nil || c.MaxAge <= 0 {
|
||||
return 86400 // 默认 24 小时
|
||||
}
|
||||
return c.MaxAge
|
||||
}
|
||||
|
||||
var (
|
||||
globalConfig *Config
|
||||
configOnce sync.Once
|
||||
configMutex sync.RWMutex
|
||||
loadErr error
|
||||
viperInstance *viper.Viper
|
||||
callbacks []func(*Config)
|
||||
callbacksMu sync.RWMutex
|
||||
)
|
||||
|
||||
// Load 加载配置文件(使用 sync.Once 确保只加载一次)
|
||||
func Load(configPath string) (*Config, error) {
|
||||
configOnce.Do(func() {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
loadErr = fmt.Errorf("读取配置文件失败: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
loadErr = fmt.Errorf("解析配置文件失败: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
configMutex.Lock()
|
||||
globalConfig = &cfg
|
||||
viperInstance = v
|
||||
configMutex.Unlock()
|
||||
})
|
||||
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
return Get(), nil
|
||||
}
|
||||
|
||||
// LoadWithWatch 加载配置文件并启用热更新
|
||||
func LoadWithWatch(configPath string, onChange func(*Config)) (*Config, error) {
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 注册回调
|
||||
if onChange != nil {
|
||||
RegisterCallback(onChange)
|
||||
}
|
||||
|
||||
// 启动文件监听
|
||||
if err := StartWatcher(); err != nil {
|
||||
return nil, fmt.Errorf("启动配置监听失败: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RegisterCallback 注册配置变更回调
|
||||
func RegisterCallback(cb func(*Config)) {
|
||||
callbacksMu.Lock()
|
||||
defer callbacksMu.Unlock()
|
||||
callbacks = append(callbacks, cb)
|
||||
}
|
||||
|
||||
// StartWatcher 启动配置文件监听
|
||||
func StartWatcher() error {
|
||||
configMutex.RLock()
|
||||
v := viperInstance
|
||||
configMutex.RUnlock()
|
||||
|
||||
if v == nil {
|
||||
return fmt.Errorf("配置未加载")
|
||||
}
|
||||
|
||||
// 使用 viper 内置的文件监听
|
||||
v.WatchConfig()
|
||||
|
||||
// 设置配置变更回调
|
||||
v.OnConfigChange(func(e fsnotify.Event) {
|
||||
var newCfg Config
|
||||
if err := v.Unmarshal(&newCfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
configMutex.Lock()
|
||||
globalConfig = &newCfg
|
||||
configMutex.Unlock()
|
||||
|
||||
// 触发所有回调
|
||||
callbacksMu.RLock()
|
||||
cbs := make([]func(*Config), len(callbacks))
|
||||
copy(cbs, callbacks)
|
||||
callbacksMu.RUnlock()
|
||||
|
||||
for _, cb := range cbs {
|
||||
cb(&newCfg)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopWatcher 停止配置文件监听
|
||||
func StopWatcher() {
|
||||
configMutex.RLock()
|
||||
v := viperInstance
|
||||
configMutex.RUnlock()
|
||||
|
||||
if v != nil {
|
||||
// viper 的 WatchConfig 没有直接的停止方法
|
||||
// 但会在 viper 实例销毁时自动停止
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取全局配置(使用读锁保护)
|
||||
func Get() *Config {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
return globalConfig
|
||||
}
|
||||
|
||||
// GetViper 获取 viper 实例(用于扩展配置)
|
||||
func GetViper() *viper.Viper {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
return viperInstance
|
||||
}
|
||||
|
||||
// Set 手动设置配置(用于测试或动态修改)
|
||||
func Set(cfg *Config) {
|
||||
configMutex.Lock()
|
||||
defer configMutex.Unlock()
|
||||
globalConfig = cfg
|
||||
}
|
||||
|
||||
// Reload 重新加载配置文件
|
||||
func Reload() error {
|
||||
configMutex.RLock()
|
||||
v := viperInstance
|
||||
configMutex.RUnlock()
|
||||
|
||||
if v == nil {
|
||||
return ErrConfigNotLoaded
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
var newCfg Config
|
||||
if err := v.Unmarshal(&newCfg); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
configMutex.Lock()
|
||||
globalConfig = &newCfg
|
||||
configMutex.Unlock()
|
||||
|
||||
// 触发回调
|
||||
callbacksMu.RLock()
|
||||
cbs := make([]func(*Config), len(callbacks))
|
||||
copy(cbs, callbacks)
|
||||
callbacksMu.RUnlock()
|
||||
|
||||
for _, cb := range cbs {
|
||||
cb(&newCfg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetString 获取字符串配置
|
||||
func GetString(key string) string {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
if viperInstance == nil {
|
||||
return ""
|
||||
}
|
||||
return viperInstance.GetString(key)
|
||||
}
|
||||
|
||||
// GetInt 获取整数配置
|
||||
func GetInt(key string) int {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
if viperInstance == nil {
|
||||
return 0
|
||||
}
|
||||
return viperInstance.GetInt(key)
|
||||
}
|
||||
|
||||
// GetBool 获取布尔配置
|
||||
func GetBool(key string) bool {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
if viperInstance == nil {
|
||||
return false
|
||||
}
|
||||
return viperInstance.GetBool(key)
|
||||
}
|
||||
|
||||
// GetStringMap 获取字符串映射配置
|
||||
func GetStringMap(key string) map[string]any {
|
||||
configMutex.RLock()
|
||||
defer configMutex.RUnlock()
|
||||
if viperInstance == nil {
|
||||
return nil
|
||||
}
|
||||
return viperInstance.GetStringMap(key)
|
||||
}
|
||||
|
||||
// IsDevelopment 是否开发环境
|
||||
func (c *Config) IsDevelopment() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
// 优先使用 App.Env
|
||||
if c.App.Env != "" {
|
||||
return c.App.IsDev()
|
||||
}
|
||||
return c.Server.Mode == "development"
|
||||
}
|
||||
|
||||
// IsProduction 是否生产环境
|
||||
func (c *Config) IsProduction() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
// 优先使用 App.Env
|
||||
if c.App.Env != "" {
|
||||
return c.App.IsProd()
|
||||
}
|
||||
return c.Server.Mode == "production"
|
||||
}
|
||||
|
||||
// GetAppName 获取应用名称
|
||||
func (c *Config) GetAppName() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.App.Name
|
||||
}
|
||||
|
||||
// GetSiteName 获取站点别名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 全局统一获取站点别名,用于缓存前缀、日志标识等
|
||||
func (c *Config) GetSiteName() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.App.GetSiteName()
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
func getTempDir() string {
|
||||
dir := os.TempDir()
|
||||
return filepath.Join(dir, "xlgo_test")
|
||||
}
|
||||
|
||||
func setupTempFile(name, content string) (string, error) {
|
||||
dir := getTempDir()
|
||||
os.MkdirAll(dir, 0755)
|
||||
path := filepath.Join(dir, name)
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
return path, err
|
||||
}
|
||||
|
||||
func TestAppConfig(t *testing.T) {
|
||||
// 测试 AppConfig 方法
|
||||
app := config.AppConfig{
|
||||
Name: "TestApp",
|
||||
SiteName: "test_site",
|
||||
Version: "1.0.0",
|
||||
Env: "dev",
|
||||
Debug: true,
|
||||
BaseURL: "https://test.example.com",
|
||||
}
|
||||
|
||||
// GetSiteName
|
||||
if app.GetSiteName() != "test_site" {
|
||||
t.Error("GetSiteName failed")
|
||||
}
|
||||
|
||||
// IsDebug
|
||||
if !app.IsDebug() {
|
||||
t.Error("IsDebug failed")
|
||||
}
|
||||
|
||||
// IsDev
|
||||
if !app.IsDev() {
|
||||
t.Error("IsDev failed")
|
||||
}
|
||||
|
||||
// IsProd
|
||||
if app.IsProd() {
|
||||
t.Error("IsProd should be false for dev")
|
||||
}
|
||||
|
||||
// 测试 nil 安全性
|
||||
var nilApp *config.AppConfig
|
||||
if nilApp.GetSiteName() != "" {
|
||||
t.Error("nil GetSiteName should return empty")
|
||||
}
|
||||
if nilApp.IsDebug() {
|
||||
t.Error("nil IsDebug should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIsProd(t *testing.T) {
|
||||
app := config.AppConfig{Env: "prod"}
|
||||
if !app.IsProd() {
|
||||
t.Error("IsProd failed for prod")
|
||||
}
|
||||
|
||||
app2 := config.AppConfig{Env: "production"}
|
||||
if !app2.IsProd() {
|
||||
t.Error("IsProd failed for production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseConfigDSN(t *testing.T) {
|
||||
db := config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 3306,
|
||||
User: "root",
|
||||
Password: "password",
|
||||
Name: "testdb",
|
||||
}
|
||||
|
||||
dsn := db.DSN()
|
||||
expected := "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
if dsn != expected {
|
||||
t.Errorf("DSN = %s, want %s", dsn, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisConfigAddr(t *testing.T) {
|
||||
redis := config.RedisConfig{
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
}
|
||||
|
||||
addr := redis.Addr()
|
||||
if addr != "localhost:6379" {
|
||||
t.Errorf("Addr = %s, want localhost:6379", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoad(t *testing.T) {
|
||||
// 创建临时配置文件
|
||||
content := `
|
||||
app:
|
||||
name: "测试应用"
|
||||
site_name: "test_api"
|
||||
version: "1.0.0"
|
||||
env: "dev"
|
||||
debug: true
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
mode: "development"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 3306
|
||||
user: "root"
|
||||
password: "test"
|
||||
name: "testdb"
|
||||
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
db: 0
|
||||
|
||||
jwt:
|
||||
secret: "test-secret"
|
||||
expire: 3600
|
||||
`
|
||||
tmpFile, err := setupTempFile("test_config.yaml", content)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile error: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
defer os.Remove(getTempDir())
|
||||
|
||||
// 重置全局状态
|
||||
config.Set(nil)
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
if cfg.App.Name != "测试应用" {
|
||||
t.Errorf("App.Name = %s", cfg.App.Name)
|
||||
}
|
||||
if cfg.App.SiteName != "test_api" {
|
||||
t.Errorf("App.SiteName = %s", cfg.App.SiteName)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Server.Port = %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Database.Host != "localhost" {
|
||||
t.Errorf("Database.Host = %s", cfg.Database.Host)
|
||||
}
|
||||
|
||||
// 测试 Get
|
||||
cfg2 := config.Get()
|
||||
if cfg2 == nil {
|
||||
t.Error("Get returned nil")
|
||||
}
|
||||
|
||||
// 测试 GetSiteName
|
||||
if cfg.GetSiteName() != "test_api" {
|
||||
t.Errorf("GetSiteName = %s", cfg.GetSiteName())
|
||||
}
|
||||
|
||||
// 测试 GetAppName
|
||||
if cfg.GetAppName() != "测试应用" {
|
||||
t.Errorf("GetAppName = %s", cfg.GetAppName())
|
||||
}
|
||||
|
||||
// 测试 IsDevelopment
|
||||
if !cfg.IsDevelopment() {
|
||||
t.Error("IsDevelopment should be true")
|
||||
}
|
||||
|
||||
// 测试 IsProduction
|
||||
if cfg.IsProduction() {
|
||||
t.Error("IsProduction should be false")
|
||||
}
|
||||
|
||||
// 测试 GetString/GetInt (子测试)
|
||||
t.Run("GetString", func(t *testing.T) {
|
||||
val := config.GetString("app.site_name")
|
||||
if val != "test_api" {
|
||||
t.Errorf("GetString = %s, want test_api", val)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetInt", func(t *testing.T) {
|
||||
port := config.GetInt("server.port")
|
||||
if port != 8080 {
|
||||
t.Errorf("GetInt = %d, want 8080", port)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetBool", func(t *testing.T) {
|
||||
if config.GetBool("nonexistent") != false {
|
||||
t.Error("GetBool should return false for nonexistent")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigGetString(_ *testing.T) {
|
||||
// 已在 TestConfigLoad 中作为子测试完成
|
||||
// 此函数保留为占位符,避免删除后影响其他测试引用
|
||||
}
|
||||
|
||||
func TestConfigSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Name: "Manual",
|
||||
SiteName: "manual_site",
|
||||
},
|
||||
}
|
||||
|
||||
config.Set(cfg)
|
||||
|
||||
if config.Get().App.Name != "Manual" {
|
||||
t.Error("Set failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigMethodsOnNil(t *testing.T) {
|
||||
// 测试 nil Config 的方法安全性
|
||||
var nilCfg *config.Config
|
||||
|
||||
if nilCfg.IsDevelopment() {
|
||||
t.Error("nil IsDevelopment should be false")
|
||||
}
|
||||
if nilCfg.IsProduction() {
|
||||
t.Error("nil IsProduction should be false")
|
||||
}
|
||||
if nilCfg.GetAppName() != "" {
|
||||
t.Error("nil GetAppName should return empty")
|
||||
}
|
||||
if nilCfg.GetSiteName() != "" {
|
||||
t.Error("nil GetSiteName should return empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageConfig(t *testing.T) {
|
||||
cfg := config.StorageConfig{
|
||||
Driver: "local",
|
||||
Local: config.LocalStorageConfig{
|
||||
Path: "/uploads",
|
||||
BaseURL: "http://localhost/uploads",
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Driver != "local" {
|
||||
t.Error("StorageConfig Driver failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogConfig(t *testing.T) {
|
||||
log := config.LogConfig{
|
||||
Dir: "/logs",
|
||||
MaxSize: 100,
|
||||
MaxBackups: 5,
|
||||
MaxAge: 30,
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
if log.Dir != "/logs" {
|
||||
t.Error("LogConfig Dir failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadConfig(t *testing.T) {
|
||||
upload := config.UploadConfig{
|
||||
MaxFileSize: 10,
|
||||
MaxVideoSize: 100,
|
||||
AllowedImageTypes: []string{"image/jpeg", "image/png"},
|
||||
}
|
||||
|
||||
if upload.MaxFileSize != 10 {
|
||||
t.Error("UploadConfig failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level 日志级别
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelSuccess
|
||||
LevelWarn
|
||||
LevelError
|
||||
)
|
||||
|
||||
// Color 颜色定义
|
||||
type Color struct {
|
||||
Code string
|
||||
Name string
|
||||
}
|
||||
|
||||
var colors = map[Level]Color{
|
||||
LevelDebug: {Code: "0;36", Name: "Debug"}, // 青色
|
||||
LevelInfo: {Code: "0;37", Name: "Info"}, // 白色
|
||||
LevelSuccess: {Code: "0;92", Name: "Success"}, // 亮绿色
|
||||
LevelWarn: {Code: "1;93", Name: "Warn"}, // 亮黄色
|
||||
LevelError: {Code: "1;31", Name: "Error"}, // 亮红色
|
||||
}
|
||||
|
||||
// Console 控制台打印器
|
||||
type Console struct {
|
||||
output io.Writer
|
||||
isColor bool
|
||||
showTime bool
|
||||
showCall bool
|
||||
timeFmt string
|
||||
skipCall int
|
||||
}
|
||||
|
||||
// Option 配置选项
|
||||
type Option func(*Console)
|
||||
|
||||
// WithOutput 设置输出目标
|
||||
func WithOutput(w io.Writer) Option {
|
||||
return func(c *Console) {
|
||||
c.output = w
|
||||
}
|
||||
}
|
||||
|
||||
// WithColor 设置是否启用颜色
|
||||
func WithColor(enable bool) Option {
|
||||
return func(c *Console) {
|
||||
c.isColor = enable
|
||||
}
|
||||
}
|
||||
|
||||
// WithTime 设置是否显示时间
|
||||
func WithTime(show bool) Option {
|
||||
return func(c *Console) {
|
||||
c.showTime = show
|
||||
}
|
||||
}
|
||||
|
||||
// WithCaller 设置是否显示调用位置
|
||||
func WithCaller(show bool, skip int) Option {
|
||||
return func(c *Console) {
|
||||
c.showCall = show
|
||||
c.skipCall = skip
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeFormat 设置时间格式
|
||||
func WithTimeFormat(fmt string) Option {
|
||||
return func(c *Console) {
|
||||
c.timeFmt = fmt
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建控制台打印器
|
||||
func New(opts ...Option) *Console {
|
||||
c := &Console{
|
||||
output: os.Stdout,
|
||||
isColor: true,
|
||||
showTime: true,
|
||||
showCall: true,
|
||||
timeFmt: "15:04:05.000",
|
||||
skipCall: 2,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Default 默认控制台
|
||||
var Default = New()
|
||||
|
||||
// print 内部打印函数
|
||||
func (c *Console) print(level Level, s ...any) {
|
||||
var sb strings.Builder
|
||||
|
||||
// 时间
|
||||
if c.showTime {
|
||||
sb.WriteString(time.Now().Format(c.timeFmt))
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
|
||||
// 级别名称
|
||||
color := colors[level]
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(color.Name)
|
||||
sb.WriteString("] ")
|
||||
|
||||
// 调用位置
|
||||
if c.showCall {
|
||||
sb.WriteString(c.getCaller())
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
|
||||
// 内容
|
||||
sb.WriteString(fmt.Sprint(s...))
|
||||
|
||||
// 输出
|
||||
if c.isColor {
|
||||
c.printColor(color.Code, sb.String())
|
||||
} else {
|
||||
fmt.Fprintln(c.output, sb.String())
|
||||
}
|
||||
}
|
||||
|
||||
// getCaller 获取调用位置
|
||||
func (c *Console) getCaller() string {
|
||||
_, file, line, ok := runtime.Caller(c.skipCall)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// 只取文件名
|
||||
idx := strings.LastIndex(file, "/")
|
||||
if idx >= 0 {
|
||||
file = file[idx+1:]
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", file, line)
|
||||
}
|
||||
|
||||
// Debug 打印调试信息(青色)
|
||||
func (c *Console) Debug(s ...any) {
|
||||
c.print(LevelDebug, s...)
|
||||
}
|
||||
|
||||
// Info 打印普通信息(白色)
|
||||
func (c *Console) Info(s ...any) {
|
||||
c.print(LevelInfo, s...)
|
||||
}
|
||||
|
||||
// Success 打印成功信息(绿色)
|
||||
func (c *Console) Success(s ...any) {
|
||||
c.print(LevelSuccess, s...)
|
||||
}
|
||||
|
||||
// Warn 打印警告信息(黄色)
|
||||
func (c *Console) Warn(s ...any) {
|
||||
c.print(LevelWarn, s...)
|
||||
}
|
||||
|
||||
// Error 打印错误信息(红色)
|
||||
func (c *Console) Error(s ...any) {
|
||||
c.print(LevelError, s...)
|
||||
}
|
||||
|
||||
// ===== 包级别便捷函数 =====
|
||||
|
||||
// Debug 使用默认控制台打印调试信息
|
||||
func Debug(s ...any) {
|
||||
Default.print(LevelDebug, s...)
|
||||
}
|
||||
|
||||
// Info 使用默认控制台打印普通信息
|
||||
func Info(s ...any) {
|
||||
Default.print(LevelInfo, s...)
|
||||
}
|
||||
|
||||
// Success 使用默认控制台打印成功信息
|
||||
func Success(s ...any) {
|
||||
Default.print(LevelSuccess, s...)
|
||||
}
|
||||
|
||||
// Warn 使用默认控制台打印警告信息
|
||||
func Warn(s ...any) {
|
||||
Default.print(LevelWarn, s...)
|
||||
}
|
||||
|
||||
// Error 使用默认控制台打印错误信息
|
||||
func Error(s ...any) {
|
||||
Default.print(LevelError, s...)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/console"
|
||||
)
|
||||
|
||||
func TestConsole(t *testing.T) {
|
||||
// 使用默认控制台
|
||||
console.Debug("这是一条调试信息")
|
||||
console.Info("这是一条普通信息")
|
||||
console.Success("这是一条成功信息")
|
||||
console.Warn("这是一条警告信息")
|
||||
console.Error("这是一条错误信息")
|
||||
|
||||
// 创建自定义控制台
|
||||
c := console.New(
|
||||
console.WithColor(true),
|
||||
console.WithTime(true),
|
||||
console.WithCaller(true, 2),
|
||||
)
|
||||
c.Debug("自定义控制台 - Debug")
|
||||
c.Info("自定义控制台 - Info")
|
||||
c.Success("自定义控制台 - Success")
|
||||
c.Warn("自定义控制台 - Warn")
|
||||
c.Error("自定义控制台 - Error")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// printColor 彩色打印(使用ANSI转义序列)
|
||||
func (c *Console) printColor(code, msg string) {
|
||||
// \033[ 是ANSI转义序列起始
|
||||
// %sm 是颜色代码
|
||||
// \033[0m 是重置颜色
|
||||
fmt.Fprintf(c.output, "\033[%sm%s\033[0m\n", code, msg)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build windows
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
// 颜色映射:ANSI -> Windows控制台颜色
|
||||
// 0 黑色, 1 蓝色, 2 绿色, 3 青色, 4 红色, 5 紫色, 6 黄色, 7 淡灰色
|
||||
// 8 灰色, 9 亮蓝色, 10 亮绿色, 11 亮青色, 12 亮红色, 13 亮紫色, 14 亮黄色, 15 白色
|
||||
var colorMap = map[string]uintptr{
|
||||
"0;36": 11, // 青色 -> 亮青色
|
||||
"0;37": 15, // 白色 -> 白色
|
||||
"0;92": 10, // 亮绿色
|
||||
"1;93": 14, // 亮黄色
|
||||
"1;31": 12, // 亮红色
|
||||
}
|
||||
|
||||
// printColor 彩色打印
|
||||
func (c *Console) printColor(code, msg string) {
|
||||
color := colorMap[code]
|
||||
if color == 0 {
|
||||
color = 7 // 默认淡灰色
|
||||
}
|
||||
|
||||
proc := kernel32.NewProc("SetConsoleTextAttribute")
|
||||
_, _, _ = proc.Call(uintptr(syscall.Stdout), color)
|
||||
fmt.Fprintln(c.output, msg)
|
||||
_, _, _ = proc.Call(uintptr(syscall.Stdout), 7) // 恢复默认颜色
|
||||
}
|
||||
|
||||
// EnableVirtualTerminal 启用虚拟终端支持(Windows 10+)
|
||||
func EnableVirtualTerminal() error {
|
||||
// 尝试启用 ANSI 转义序列支持
|
||||
proc := kernel32.NewProc("GetConsoleMode")
|
||||
var mode uint32
|
||||
_, _, err := proc.Call(uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&mode)))
|
||||
if err != syscall.Errno(0) {
|
||||
return err
|
||||
}
|
||||
|
||||
mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
proc = kernel32.NewProc("SetConsoleMode")
|
||||
_, _, err = proc.Call(uintptr(syscall.Stdout), uintptr(mode))
|
||||
return err
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task 定时任务
|
||||
type Task struct {
|
||||
Name string // 任务名称
|
||||
Schedule Schedule // 调度规则
|
||||
Handler TaskHandler // 任务处理函数
|
||||
Enabled bool // 是否启用
|
||||
LastRun time.Time // 上次运行时间
|
||||
NextRun time.Time // 下次运行时间
|
||||
RunCount int // 运行次数
|
||||
}
|
||||
|
||||
// TaskHandler 任务处理函数
|
||||
type TaskHandler func(ctx context.Context) error
|
||||
|
||||
// Schedule 调度接口
|
||||
type Schedule interface {
|
||||
Next(now time.Time) time.Time
|
||||
}
|
||||
|
||||
// Scheduler 调度器
|
||||
type Scheduler struct {
|
||||
tasks map[string]*Task
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewScheduler 创建调度器
|
||||
func NewScheduler() *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Scheduler{
|
||||
tasks: make(map[string]*Task),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// AddTask 添加任务
|
||||
func (s *Scheduler) AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
|
||||
task := &Task{
|
||||
Name: name,
|
||||
Schedule: schedule,
|
||||
Handler: handler,
|
||||
Enabled: true,
|
||||
NextRun: schedule.Next(time.Now()),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.tasks[name] = task
|
||||
s.mu.Unlock()
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
// RemoveTask 移除任务
|
||||
func (s *Scheduler) RemoveTask(name string) {
|
||||
s.mu.Lock()
|
||||
delete(s.tasks, name)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// EnableTask 启用任务
|
||||
func (s *Scheduler) EnableTask(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
task, ok := s.tasks[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
|
||||
task.Enabled = true
|
||||
task.NextRun = task.Schedule.Next(time.Now())
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableTask 禁用任务
|
||||
func (s *Scheduler) DisableTask(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
task, ok := s.tasks[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
|
||||
task.Enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTask 获取任务
|
||||
func (s *Scheduler) GetTask(name string) (*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
task, ok := s.tasks[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// ListTasks 获取所有任务
|
||||
func (s *Scheduler) ListTasks() []*Task {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
tasks := make([]*Task, 0, len(s.tasks))
|
||||
for _, task := range s.tasks {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
// RunTask 立即运行任务
|
||||
func (s *Scheduler) RunTask(name string) error {
|
||||
s.mu.RLock()
|
||||
task, ok := s.tasks[name]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("任务不存在: %s", name)
|
||||
}
|
||||
|
||||
return s.runTask(task)
|
||||
}
|
||||
|
||||
// runTask 执行任务
|
||||
func (s *Scheduler) runTask(task *Task) error {
|
||||
task.LastRun = time.Now()
|
||||
task.RunCount++
|
||||
|
||||
err := task.Handler(s.ctx)
|
||||
|
||||
s.mu.Lock()
|
||||
task.NextRun = task.Schedule.Next(time.Now())
|
||||
s.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Start 启动调度器
|
||||
func (s *Scheduler) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// Stop 停止调度器
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// run 运行调度循环
|
||||
func (s *Scheduler) run() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.checkAndRun()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndRun 检查并运行到期任务
|
||||
func (s *Scheduler) checkAndRun() {
|
||||
now := time.Now()
|
||||
|
||||
s.mu.RLock()
|
||||
for _, task := range s.tasks {
|
||||
if task.Enabled && !task.NextRun.IsZero() && now.After(task.NextRun) {
|
||||
go s.runTask(task)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
// IntervalSchedule 固定间隔调度
|
||||
type IntervalSchedule struct {
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
func (s *IntervalSchedule) Next(now time.Time) time.Time {
|
||||
return now.Add(s.Interval)
|
||||
}
|
||||
|
||||
// Every 每隔指定时间运行
|
||||
func Every(interval time.Duration) *IntervalSchedule {
|
||||
return &IntervalSchedule{Interval: interval}
|
||||
}
|
||||
|
||||
// DailySchedule 每日定时调度
|
||||
type DailySchedule struct {
|
||||
Hour int
|
||||
Minute int
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
func (s *DailySchedule) Next(now time.Time) time.Time {
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), s.Hour, s.Minute, 0, 0, now.Location())
|
||||
if next.Before(now) || next.Equal(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Daily 每日指定时间运行
|
||||
func Daily(hour, minute int) *DailySchedule {
|
||||
return &DailySchedule{Hour: hour, Minute: minute}
|
||||
}
|
||||
|
||||
// WeeklySchedule 每周定时调度
|
||||
type WeeklySchedule struct {
|
||||
Day time.Weekday
|
||||
Hour int
|
||||
Minute int
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
func (s *WeeklySchedule) Next(now time.Time) time.Time {
|
||||
daysUntil := int(s.Day) - int(now.Weekday())
|
||||
if daysUntil <= 0 {
|
||||
daysUntil += 7
|
||||
}
|
||||
|
||||
next := time.Date(now.Year(), now.Month(), now.Day()+daysUntil, s.Hour, s.Minute, 0, 0, now.Location())
|
||||
return next
|
||||
}
|
||||
|
||||
// Weekly 每周指定时间运行
|
||||
func Weekly(day time.Weekday, hour, minute int) *WeeklySchedule {
|
||||
return &WeeklySchedule{Day: day, Hour: hour, Minute: minute}
|
||||
}
|
||||
|
||||
// FullCronSchedule 完整 Cron 表达式调度
|
||||
// 格式: "分钟 小时 日 月 星期" (5字段)
|
||||
// 示例: "0 12 * * *" 每天12点
|
||||
// "0 0 1 * *" 每月1号凌晨
|
||||
// "0 9-17 * * 1-5" 周一到周五 9点到17点每小时
|
||||
type FullCronSchedule struct {
|
||||
Minute string // 分钟: 0-59, "*", "*/n", "a-b", "a,b,c"
|
||||
Hour string // 小时: 0-23, "*", "*/n", "a-b", "a,b,c"
|
||||
Day string // 日: 1-31, "*", "*/n", "a-b", "a,b,c"
|
||||
Month string // 月: 1-12, "*", "*/n", "a-b", "a,b,c"
|
||||
Weekday string // 星期: 0-6 (周日=0), "*", "*/n", "a-b", "a,b,c"
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
func (s *FullCronSchedule) Next(now time.Time) time.Time {
|
||||
// 从下一分钟开始查找
|
||||
next := now.Add(time.Minute)
|
||||
next = time.Date(next.Year(), next.Month(), next.Day(), next.Hour(), next.Minute(), 0, 0, next.Location())
|
||||
|
||||
// 最多查找一年(防止无效表达式死循环)
|
||||
maxAttempts := 366 * 24 * 60
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if s.match(next) {
|
||||
return next
|
||||
}
|
||||
next = next.Add(time.Minute)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// match 检查时间是否匹配表达式
|
||||
func (s *FullCronSchedule) match(t time.Time) bool {
|
||||
return s.matchField(s.Minute, t.Minute(), 0, 59) &&
|
||||
s.matchField(s.Hour, t.Hour(), 0, 23) &&
|
||||
s.matchField(s.Day, t.Day(), 1, 31) &&
|
||||
s.matchField(s.Month, int(t.Month()), 1, 12) &&
|
||||
s.matchField(s.Weekday, int(t.Weekday()), 0, 6)
|
||||
}
|
||||
|
||||
// matchField 匹配单个字段
|
||||
func (s *FullCronSchedule) matchField(field string, value int, min, max int) bool {
|
||||
if field == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理范围 "a-b"
|
||||
if strings.Contains(field, "-") {
|
||||
parts := strings.Split(field, "-")
|
||||
if len(parts) == 2 {
|
||||
start := parseInt(parts[0])
|
||||
end := parseInt(parts[1])
|
||||
return value >= start && value <= end
|
||||
}
|
||||
}
|
||||
|
||||
// 处理步长 "*/n"
|
||||
if strings.HasPrefix(field, "*/") {
|
||||
step := parseInt(strings.TrimPrefix(field, "*/"))
|
||||
if step > 0 {
|
||||
return (value - min) % step == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理列表 "a,b,c"
|
||||
for _, p := range strings.Split(field, ",") {
|
||||
if parseInt(strings.TrimSpace(p)) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseCron 解析完整 Cron 表达式
|
||||
// 格式: "分钟 小时 日 月 星期"
|
||||
// 示例:
|
||||
//
|
||||
// "0 12 * * *" - 每天12:00
|
||||
// "*/15 * * * *" - 每15分钟
|
||||
// "0 9-17 * * 1-5" - 工作日9-17点每小时
|
||||
// "0 0 1 * *" - 每月1号凌晨
|
||||
// "0 0 * * 0" - 每周日凌晨
|
||||
func ParseCron(expr string) *FullCronSchedule {
|
||||
fields := strings.Fields(expr)
|
||||
if len(fields) != 5 {
|
||||
// 默认每分钟执行
|
||||
return &FullCronSchedule{"*", "*", "*", "*", "*"}
|
||||
}
|
||||
return &FullCronSchedule{
|
||||
Minute: fields[0],
|
||||
Hour: fields[1],
|
||||
Day: fields[2],
|
||||
Month: fields[3],
|
||||
Weekday: fields[4],
|
||||
}
|
||||
}
|
||||
|
||||
// CronSchedule 简化 Cron 调度(仅分钟和小时)
|
||||
type CronSchedule struct {
|
||||
Minute string // 分钟: "*" 或具体值如 "0,15,30"
|
||||
Hour string // 小时: "*" 或具体值如 "8,12"
|
||||
}
|
||||
|
||||
// Next 计算下次运行时间
|
||||
func (s *CronSchedule) Next(now time.Time) time.Time {
|
||||
for i := 1; i <= 60*24; i++ { // 最多查找24小时
|
||||
next := now.Add(time.Duration(i) * time.Minute)
|
||||
if s.matchMinute(next.Minute()) && s.matchHour(next.Hour()) {
|
||||
return next
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (s *CronSchedule) matchMinute(minute int) bool {
|
||||
return s.Minute == "*" || s.matchValue(s.Minute, minute)
|
||||
}
|
||||
|
||||
func (s *CronSchedule) matchHour(hour int) bool {
|
||||
return s.Hour == "*" || s.matchValue(s.Hour, hour)
|
||||
}
|
||||
|
||||
func (s *CronSchedule) matchValue(pattern string, value int) bool {
|
||||
for _, p := range splitPattern(pattern) {
|
||||
if p == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitPattern(pattern string) []int {
|
||||
var values []int
|
||||
for _, p := range split(pattern, ',') {
|
||||
v := parseInt(p)
|
||||
if v >= 0 {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func split(s string, sep byte) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == sep {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts = append(parts, s[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var v int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
v = v*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Cron 创建类 Cron 调度
|
||||
func Cron(minute, hour string) *CronSchedule {
|
||||
return &CronSchedule{Minute: minute, Hour: hour}
|
||||
}
|
||||
|
||||
// 全局调度器
|
||||
var globalScheduler *Scheduler
|
||||
var schedulerOnce sync.Once
|
||||
|
||||
// GetScheduler 获取全局调度器
|
||||
func GetScheduler() *Scheduler {
|
||||
schedulerOnce.Do(func() {
|
||||
globalScheduler = NewScheduler()
|
||||
})
|
||||
return globalScheduler
|
||||
}
|
||||
|
||||
// AddTask 添加任务到全局调度器
|
||||
func AddTask(name string, schedule Schedule, handler TaskHandler) *Task {
|
||||
return GetScheduler().AddTask(name, schedule, handler)
|
||||
}
|
||||
|
||||
// Start 启动全局调度器
|
||||
func Start() {
|
||||
GetScheduler().Start()
|
||||
}
|
||||
|
||||
// Stop 停止全局调度器
|
||||
func Stop() {
|
||||
GetScheduler().Stop()
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package cron_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cron"
|
||||
)
|
||||
|
||||
func TestTask(t *testing.T) {
|
||||
task := cron.Task{
|
||||
Name: "test_task",
|
||||
Enabled: true,
|
||||
RunCount: 0,
|
||||
}
|
||||
|
||||
if task.Name != "test_task" {
|
||||
t.Error("Task Name failed")
|
||||
}
|
||||
if !task.Enabled {
|
||||
t.Error("Task Enabled failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewScheduler(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
if scheduler == nil {
|
||||
t.Error("NewScheduler should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTask(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
task := scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if task == nil {
|
||||
t.Error("AddTask should return task")
|
||||
}
|
||||
if task.Name != "test" {
|
||||
t.Errorf("Task name = %s, want test", task.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTask(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
scheduler.RemoveTask("test")
|
||||
|
||||
task, err := scheduler.GetTask("test")
|
||||
if err == nil {
|
||||
t.Error("RemoveTask should remove task")
|
||||
}
|
||||
if task != nil {
|
||||
t.Error("Removed task should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableDisableTask(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
scheduler.AddTask("test", cron.Every(time.Minute), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
err := scheduler.DisableTask("test")
|
||||
if err != nil {
|
||||
t.Errorf("DisableTask error: %v", err)
|
||||
}
|
||||
|
||||
task, _ := scheduler.GetTask("test")
|
||||
if task.Enabled {
|
||||
t.Error("Task should be disabled")
|
||||
}
|
||||
|
||||
err = scheduler.EnableTask("test")
|
||||
if err != nil {
|
||||
t.Errorf("EnableTask error: %v", err)
|
||||
}
|
||||
|
||||
task, _ = scheduler.GetTask("test")
|
||||
if !task.Enabled {
|
||||
t.Error("Task should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTasks(t *testing.T) {
|
||||
scheduler := cron.NewScheduler()
|
||||
scheduler.AddTask("task1", cron.Every(time.Minute), func(ctx context.Context) error { return nil })
|
||||
scheduler.AddTask("task2", cron.Every(time.Hour), func(ctx context.Context) error { return nil })
|
||||
|
||||
tasks := scheduler.ListTasks()
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("ListTasks length = %d, want 2", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalSchedule(t *testing.T) {
|
||||
schedule := cron.IntervalSchedule{Interval: time.Minute}
|
||||
now := time.Now()
|
||||
next := schedule.Next(now)
|
||||
|
||||
if next.Before(now) {
|
||||
t.Error("Next should be after now")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvery(t *testing.T) {
|
||||
schedule := cron.Every(5 * time.Minute)
|
||||
if schedule.Interval != 5*time.Minute {
|
||||
t.Errorf("Every interval = %v, want 5m", schedule.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySchedule(t *testing.T) {
|
||||
schedule := cron.DailySchedule{Hour: 9, Minute: 30}
|
||||
now := time.Now()
|
||||
next := schedule.Next(now)
|
||||
|
||||
if next.Hour() != 9 || next.Minute() != 30 {
|
||||
t.Errorf("DailySchedule next = %v, want 09:30", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily(t *testing.T) {
|
||||
schedule := cron.Daily(10, 0)
|
||||
if schedule.Hour != 10 || schedule.Minute != 0 {
|
||||
t.Error("Daily failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeeklySchedule(t *testing.T) {
|
||||
schedule := cron.WeeklySchedule{Day: time.Monday, Hour: 9, Minute: 0}
|
||||
now := time.Now()
|
||||
next := schedule.Next(now)
|
||||
|
||||
if next.Hour() != 9 {
|
||||
t.Errorf("WeeklySchedule hour = %d, want 9", next.Hour())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekly(t *testing.T) {
|
||||
schedule := cron.Weekly(time.Monday, 10, 0)
|
||||
if schedule.Day != time.Monday {
|
||||
t.Error("Weekly Day failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronSchedule(t *testing.T) {
|
||||
schedule := cron.CronSchedule{Minute: "0,15,30", Hour: "9"}
|
||||
now := time.Now()
|
||||
next := schedule.Next(now)
|
||||
|
||||
if next.IsZero() {
|
||||
t.Error("CronSchedule should find next time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCron(t *testing.T) {
|
||||
schedule := cron.Cron("0", "9")
|
||||
if schedule.Minute != "0" || schedule.Hour != "9" {
|
||||
t.Error("Cron failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScheduler(t *testing.T) {
|
||||
scheduler := cron.GetScheduler()
|
||||
if scheduler == nil {
|
||||
t.Error("GetScheduler should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalAddTask(t *testing.T) {
|
||||
task := cron.AddTask("global_test", cron.Every(time.Hour), func(ctx context.Context) error {
|
||||
return nil
|
||||
})
|
||||
if task == nil {
|
||||
t.Error("Global AddTask should return task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskHandler(t *testing.T) {
|
||||
var handler cron.TaskHandler = func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
if handler == nil {
|
||||
t.Error("TaskHandler should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullCronSchedule(t *testing.T) {
|
||||
// 测试每15分钟
|
||||
schedule := cron.ParseCron("*/15 * * * *")
|
||||
now := time.Now()
|
||||
next := schedule.Next(now)
|
||||
if next.IsZero() {
|
||||
t.Error("FullCronSchedule */15 should find next time")
|
||||
}
|
||||
// 验证分钟是0,15,30,45之一
|
||||
minute := next.Minute()
|
||||
if minute%15 != 0 {
|
||||
t.Errorf("FullCronSchedule minute = %d, should be divisible by 15", minute)
|
||||
}
|
||||
|
||||
// 测试每天12点
|
||||
schedule = cron.ParseCron("0 12 * * *")
|
||||
next = schedule.Next(now)
|
||||
if next.IsZero() {
|
||||
t.Error("FullCronSchedule daily noon should find next time")
|
||||
}
|
||||
if next.Hour() != 12 || next.Minute() != 0 {
|
||||
t.Errorf("FullCronSchedule noon = %v, want 12:00", next)
|
||||
}
|
||||
|
||||
// 测试工作日9-17点
|
||||
schedule = cron.ParseCron("0 9-17 * * 1-5")
|
||||
next = schedule.Next(now)
|
||||
if next.IsZero() {
|
||||
t.Error("FullCronSchedule workday should find next time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCron(t *testing.T) {
|
||||
// 正常5字段表达式
|
||||
schedule := cron.ParseCron("0 12 * * *")
|
||||
if schedule.Minute != "0" || schedule.Hour != "12" {
|
||||
t.Error("ParseCron 5 fields failed")
|
||||
}
|
||||
|
||||
// 无效表达式返回默认
|
||||
schedule = cron.ParseCron("invalid")
|
||||
if schedule.Minute != "*" || schedule.Hour != "*" {
|
||||
t.Error("ParseCron invalid should return default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullCronScheduleMatch(t *testing.T) {
|
||||
schedule := cron.FullCronSchedule{
|
||||
Minute: "*/5",
|
||||
Hour: "*",
|
||||
Day: "*",
|
||||
Month: "*",
|
||||
Weekday: "*",
|
||||
}
|
||||
|
||||
// 测试匹配 - 通过Next验证
|
||||
testTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
next := schedule.Next(testTime)
|
||||
// 10:00 下一个应该是 10:05
|
||||
if next.Minute() != 5 || next.Hour() != 10 {
|
||||
t.Errorf("Next after 10:00 should be 10:05, got %v", next)
|
||||
}
|
||||
|
||||
testTime = time.Date(2024, 1, 15, 10, 7, 0, 0, time.UTC)
|
||||
next = schedule.Next(testTime)
|
||||
// 10:07 下一个应该是 10:10
|
||||
if next.Minute() != 10 || next.Hour() != 10 {
|
||||
t.Errorf("Next after 10:07 should be 10:10, got %v", next)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
// DB 主库实例(写操作)
|
||||
DB *gorm.DB
|
||||
// DBRead 读库实例(读操作)
|
||||
DBRead *gorm.DB
|
||||
// replicas 从库列表
|
||||
replicas []*gorm.DB
|
||||
// replicaMutex 从库选择锁
|
||||
replicaMutex sync.Mutex
|
||||
)
|
||||
|
||||
// InitMySQL 初始化 MySQL 连接(带重试机制)
|
||||
func InitMySQL(cfg *config.Config) error {
|
||||
var err error
|
||||
|
||||
// GORM 日志配置
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
maxRetries := 5
|
||||
retryDelay := time.Second
|
||||
|
||||
for i := range maxRetries {
|
||||
// 连接主库
|
||||
DB, err = gorm.Open(mysql.Open(cfg.Database.DSN()), gormConfig)
|
||||
if err == nil {
|
||||
sqlDB, err := DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err == nil {
|
||||
logger.Info("MySQL 主库连接成功", zap.String("host", cfg.Database.Host), zap.Int("port", cfg.Database.Port))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Warnf("MySQL 连接失败,第 %d/%d 次重试: %v", i+1, maxRetries, err)
|
||||
time.Sleep(retryDelay)
|
||||
retryDelay *= 2
|
||||
if retryDelay > 30*time.Second {
|
||||
retryDelay = 30 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("MySQL 连接失败(重试 %d 次): %w", maxRetries, err)
|
||||
}
|
||||
|
||||
// InitMySQLWithReplicas 初始化 MySQL 主从连接
|
||||
// masterDSN: 主库连接字符串
|
||||
// replicaDSNs: 从库连接字符串列表
|
||||
func InitMySQLWithReplicas(cfg *config.Config, replicaDSNs []string) error {
|
||||
// 先初始化主库
|
||||
if err := InitMySQL(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化从库
|
||||
if len(replicaDSNs) > 0 {
|
||||
var gormLogLevel gormlogger.LogLevel
|
||||
if cfg.IsDevelopment() {
|
||||
gormLogLevel = gormlogger.Info
|
||||
} else {
|
||||
gormLogLevel = gormlogger.Warn
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: gormlogger.Default.LogMode(gormLogLevel),
|
||||
}
|
||||
|
||||
for i, dsn := range replicaDSNs {
|
||||
replicaDB, err := gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
if err != nil {
|
||||
logger.Warnf("MySQL 从库 %d 连接失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB, err := replicaDB.DB()
|
||||
if err != nil {
|
||||
logger.Warnf("MySQL 从库 %d 获取连接池失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns / 2) // 从库连接数可适当减少
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
logger.Warnf("MySQL 从库 %d Ping 失败: %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
replicas = append(replicas, replicaDB)
|
||||
logger.Info("MySQL 从库连接成功", zap.Int("index", i+1))
|
||||
}
|
||||
|
||||
// 设置默认读库
|
||||
if len(replicas) > 0 {
|
||||
DBRead = replicas[0]
|
||||
} else {
|
||||
DBRead = DB // 无从库时使用主库
|
||||
}
|
||||
} else {
|
||||
DBRead = DB // 无从库配置时使用主库
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetReadDB 获取读库实例(自动选择从库)
|
||||
func GetReadDB() *gorm.DB {
|
||||
if len(replicas) == 0 {
|
||||
return DB
|
||||
}
|
||||
|
||||
replicaMutex.Lock()
|
||||
defer replicaMutex.Unlock()
|
||||
|
||||
// 随机选择一个从库
|
||||
idx := rand.Intn(len(replicas))
|
||||
return replicas[idx]
|
||||
}
|
||||
|
||||
// GetWriteDB 获取写库实例(主库)
|
||||
func GetWriteDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例(默认主库,兼容旧代码)
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GetReplicas 获取所有从库实例
|
||||
func GetReplicas() []*gorm.DB {
|
||||
return replicas
|
||||
}
|
||||
|
||||
// UseMaster 强制使用主库(用于事务或需要实时数据的场景)
|
||||
func UseMaster(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, "db_mode", "master")
|
||||
}
|
||||
|
||||
// UseReplica 强制使用从库(用于报表查询等场景)
|
||||
func UseReplica(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, "db_mode", "replica")
|
||||
}
|
||||
|
||||
// GetDBFromContext 根据上下文选择数据库
|
||||
func GetDBFromContext(ctx context.Context) *gorm.DB {
|
||||
mode, ok := ctx.Value("db_mode").(string)
|
||||
if !ok {
|
||||
return GetReadDB()
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "master":
|
||||
return DB
|
||||
case "replica":
|
||||
return GetReadDB()
|
||||
default:
|
||||
return GetReadDB()
|
||||
}
|
||||
}
|
||||
|
||||
// DBResolver 数据库解析器(用于 GORM 钩子)
|
||||
type DBResolver struct{}
|
||||
|
||||
// BeforeQuery 查询前钩子,自动路由到从库
|
||||
func (r *DBResolver) BeforeQuery(db *gorm.DB) {
|
||||
// 如果在事务中,使用主库
|
||||
if db.Statement.ConnPool != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查上下文
|
||||
ctx := db.Statement.Context
|
||||
if ctx != nil {
|
||||
mode, ok := ctx.Value("db_mode").(string)
|
||||
if ok && mode == "master" {
|
||||
return // 强制主库
|
||||
}
|
||||
}
|
||||
|
||||
// 读操作路由到从库
|
||||
if len(replicas) > 0 && DBRead != nil {
|
||||
db.Statement.ConnPool = DBRead.Statement.ConnPool
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate 自动迁移数据库表结构(由应用重写)
|
||||
func AutoMigrate() error {
|
||||
logger.Info("数据库表结构迁移完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if DB == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// CloseAll 关闭所有数据库连接(包括从库)
|
||||
func CloseAll() error {
|
||||
// 关闭主库
|
||||
if err := Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 关闭从库
|
||||
for _, replica := range replicas {
|
||||
if replica == nil {
|
||||
continue
|
||||
}
|
||||
sqlDB, err := replica.DB()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
replicas = nil
|
||||
DBRead = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transaction 事务操作(自动使用主库)
|
||||
func Transaction(fn func(tx *gorm.DB) error) error {
|
||||
return DB.Transaction(fn)
|
||||
}
|
||||
|
||||
// TransactionWithContext 带上下文的事务操作
|
||||
func TransactionWithContext(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||
return DB.WithContext(ctx).Transaction(fn)
|
||||
}
|
||||
|
||||
// ReadQuery 读查询(自动路由到从库)
|
||||
func ReadQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
db := GetDBFromContext(ctx)
|
||||
return db.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// WriteQuery 写查询(强制使用主库)
|
||||
func WriteQuery(ctx context.Context, model any, query string, args ...any) error {
|
||||
return DB.WithContext(ctx).Where(query, args...).Find(model).Error
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func HealthCheck() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
|
||||
// 检查主库
|
||||
if DB != nil {
|
||||
sqlDB, err := DB.DB()
|
||||
if err == nil && sqlDB.Ping() == nil {
|
||||
result["master"] = true
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
} else {
|
||||
result["master"] = false
|
||||
}
|
||||
|
||||
// 检查从库
|
||||
for i, replica := range replicas {
|
||||
if replica != nil {
|
||||
sqlDB, err := replica.DB()
|
||||
if err == nil && sqlDB.Ping() == nil {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = true
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
} else {
|
||||
result[fmt.Sprintf("replica_%d", i+1)] = false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// RedisClient 全局 Redis 客户端
|
||||
RedisClient *redis.Client
|
||||
)
|
||||
|
||||
// InitRedis 初始化 Redis 连接
|
||||
func InitRedis(cfg *config.Config) error {
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
})
|
||||
|
||||
// 测试连接
|
||||
ctx := context.Background()
|
||||
if err := RedisClient.Ping(ctx).Err(); err != nil {
|
||||
return fmt.Errorf("Redis 连接失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Redis 连接成功", zap.String("addr", cfg.Redis.Addr()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseRedis 关闭 Redis 连接
|
||||
func CloseRedis() error {
|
||||
if RedisClient != nil {
|
||||
return RedisClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRedis 获取 Redis 客户端
|
||||
func GetRedis() *redis.Client {
|
||||
return RedisClient
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
module github.com/EthanCodeCraft/xlgo-core
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-playground/validator/v10 v10.19.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.5.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/text v0.35.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/mysql v1.5.4
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/swag v1.16.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiwbXTpUEinBpHsN7mG21Rc2k=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
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=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
|
||||
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
|
||||
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.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-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.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-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.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=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso=
|
||||
gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs=
|
||||
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,176 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthCheck 健康检查
|
||||
// @Summary 健康检查
|
||||
// @Description 检查服务是否正常运行
|
||||
// @Tags 系统
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /health [get]
|
||||
func HealthCheck(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// BindJSON 绑定 JSON 请求
|
||||
func BindJSON(c *gin.Context, req any) error {
|
||||
return c.ShouldBindJSON(req)
|
||||
}
|
||||
|
||||
// BindQuery 绑定 Query 参数
|
||||
func BindQuery(c *gin.Context, req any) error {
|
||||
return c.ShouldBindQuery(req)
|
||||
}
|
||||
|
||||
// GetPage 获取分页参数
|
||||
func GetPage(c *gin.Context) (int, int) {
|
||||
page := c.DefaultQuery("page", "1")
|
||||
pageSize := c.DefaultQuery("page_size", "20")
|
||||
|
||||
p := utils.ToIntDefault(page, 1)
|
||||
ps := utils.ToIntDefault(pageSize, 20)
|
||||
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if ps < 1 {
|
||||
ps = 20
|
||||
}
|
||||
if ps > 100 {
|
||||
ps = 100
|
||||
}
|
||||
|
||||
return p, ps
|
||||
}
|
||||
|
||||
// GetIDFromPath 从路径获取 ID
|
||||
func GetIDFromPath(c *gin.Context, paramName string) (uint, bool) {
|
||||
idStr := c.Param(paramName)
|
||||
idStr = strings.Trim(idStr, "/")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// ===== 类型安全的参数获取函数 =====
|
||||
|
||||
// QueryInt 获取 Query 参数中的整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 类型安全,避免每次手动转换
|
||||
func QueryInt(c *gin.Context, key string, def int) int {
|
||||
return utils.ToIntDefault(c.Query(key), def)
|
||||
}
|
||||
|
||||
// QueryInt64 获取 Query 参数中的 int64(带默认值)
|
||||
func QueryInt64(c *gin.Context, key string, def int64) int64 {
|
||||
return utils.ToInt64Default(c.Query(key), def)
|
||||
}
|
||||
|
||||
// QueryFloat64 获取 Query 参数中的 float64(带默认值)
|
||||
func QueryFloat64(c *gin.Context, key string, def float64) float64 {
|
||||
return utils.ToFloat64Default(c.Query(key), def)
|
||||
}
|
||||
|
||||
// QueryBool 获取 Query 参数中的布尔值(带默认值)
|
||||
func QueryBool(c *gin.Context, key string, def bool) bool {
|
||||
val := c.Query(key)
|
||||
if val == "" {
|
||||
return def
|
||||
}
|
||||
return val == "true" || val == "1" || val == "yes"
|
||||
}
|
||||
|
||||
// PathInt 从路径参数获取整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: RESTful API 常用,如 /users/:id
|
||||
func PathInt(c *gin.Context, key string, def int) int {
|
||||
val := strings.Trim(c.Param(key), "/")
|
||||
return utils.ToIntDefault(val, def)
|
||||
}
|
||||
|
||||
// PathInt64 从路径参数获取 int64(带默认值)
|
||||
func PathInt64(c *gin.Context, key string, def int64) int64 {
|
||||
val := strings.Trim(c.Param(key), "/")
|
||||
return utils.ToInt64Default(val, def)
|
||||
}
|
||||
|
||||
// PathUint64 从路径参数获取 uint64(带默认值)
|
||||
func PathUint64(c *gin.Context, key string, def uint64) uint64 {
|
||||
val := strings.Trim(c.Param(key), "/")
|
||||
return utils.ToUint64Default(val, def)
|
||||
}
|
||||
|
||||
// FormInt 获取 POST 表单参数中的整数(带默认值)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 传统表单提交常用
|
||||
func FormInt(c *gin.Context, key string, def int) int {
|
||||
return utils.ToIntDefault(c.PostForm(key), def)
|
||||
}
|
||||
|
||||
// FormInt64 获取 POST 表单参数中的 int64(带默认值)
|
||||
func FormInt64(c *gin.Context, key string, def int64) int64 {
|
||||
return utils.ToInt64Default(c.PostForm(key), def)
|
||||
}
|
||||
|
||||
// FormUint64 获取 POST 表单参数中的 uint64(带默认值)
|
||||
func FormUint64(c *gin.Context, key string, def uint64) uint64 {
|
||||
return utils.ToUint64Default(c.PostForm(key), def)
|
||||
}
|
||||
|
||||
// FormFloat64 获取 POST 表单参数中的 float64(带默认值)
|
||||
func FormFloat64(c *gin.Context, key string, def float64) float64 {
|
||||
return utils.ToFloat64Default(c.PostForm(key), def)
|
||||
}
|
||||
|
||||
// FormBool 获取 POST 表单参数中的布尔值(带默认值)
|
||||
func FormBool(c *gin.Context, key string, def bool) bool {
|
||||
val := c.PostForm(key)
|
||||
if val == "" {
|
||||
return def
|
||||
}
|
||||
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||
}
|
||||
|
||||
// FormString 获取 POST 表单参数中的字符串(带默认值)
|
||||
func FormString(c *gin.Context, key string, def string) string {
|
||||
val := c.PostForm(key)
|
||||
if val == "" {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// ParseInt 解析整数
|
||||
func ParseInt(s string, defaultValue int) int {
|
||||
return utils.ToIntDefault(s, defaultValue)
|
||||
}
|
||||
|
||||
// BadRequest 返回 400 错误
|
||||
func BadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, response.Response{
|
||||
Code: response.CodeFail,
|
||||
Msg: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// InternalError 返回 500 错误
|
||||
func InternalError(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusInternalServerError, response.Response{
|
||||
Code: response.CodeServerError,
|
||||
Msg: msg,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/handler"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/health", handler.HealthCheck)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("HealthCheck status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryInt(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
page := handler.QueryInt(c, "page", 1)
|
||||
c.JSON(200, gin.H{"page": page})
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
expected int
|
||||
}{
|
||||
{"?page=10", 10},
|
||||
{"?page=abc", 1}, // 无效返回默认
|
||||
{"", 1}, // 无参数返回默认
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test"+tt.query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证响应包含正确的值
|
||||
if w.Code != 200 {
|
||||
t.Errorf("QueryInt status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryInt64(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
id := handler.QueryInt64(c, "id", 0)
|
||||
c.JSON(200, gin.H{"id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test?id=1234567890123", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("QueryInt64 status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFloat64(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
price := handler.QueryFloat64(c, "price", 0.0)
|
||||
c.JSON(200, gin.H{"price": price})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test?price=99.99", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("QueryFloat64 status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryBool(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
enabled := handler.QueryBool(c, "enabled", false)
|
||||
c.JSON(200, gin.H{"enabled": enabled})
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
expected bool
|
||||
}{
|
||||
{"?enabled=true", true},
|
||||
{"?enabled=1", true},
|
||||
{"?enabled=yes", true},
|
||||
{"?enabled=false", false},
|
||||
{"?enabled=0", false},
|
||||
{"", false}, // 默认值
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test"+tt.query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("QueryBool status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathInt(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/user/:id", func(c *gin.Context) {
|
||||
id := handler.PathInt(c, "id", 0)
|
||||
c.JSON(200, gin.H{"id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/user/123", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("PathInt status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathInt64(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/user/:id", func(c *gin.Context) {
|
||||
id := handler.PathInt64(c, "id", 0)
|
||||
c.JSON(200, gin.H{"id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/user/1234567890123", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("PathInt64 status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPage(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/list", func(c *gin.Context) {
|
||||
page, pageSize := handler.GetPage(c)
|
||||
c.JSON(200, gin.H{"page": page, "pageSize": pageSize})
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
query string
|
||||
expectedPage int
|
||||
expectedSize int
|
||||
}{
|
||||
{"?page=2&page_size=50", 2, 50},
|
||||
{"?page=0&page_size=0", 1, 20}, // 边界修正
|
||||
{"?page=-1&page_size=200", 1, 100}, // 超限修正
|
||||
{"", 1, 20}, // 默认值
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/list"+tt.query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("GetPage status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIDFromPath(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/user/:id", func(c *gin.Context) {
|
||||
id, ok := handler.GetIDFromPath(c, "id")
|
||||
c.JSON(200, gin.H{"id": id, "ok": ok})
|
||||
})
|
||||
|
||||
// 有效 ID
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/user/123", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("GetIDFromPath status = %d", w.Code)
|
||||
}
|
||||
|
||||
// 无效 ID
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/user/abc", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != 200 {
|
||||
t.Errorf("GetIDFromPath invalid status = %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadRequest(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.BadRequest(c, "参数错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("BadRequest status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalError(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
handler.InternalError(c, "服务器错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("InternalError status = %d, want 500", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindJSON(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.POST("/test", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := handler.BindJSON(c, &req); err != nil {
|
||||
handler.BadRequest(c, "绑定失败")
|
||||
return
|
||||
}
|
||||
c.JSON(200, req)
|
||||
})
|
||||
|
||||
// 有效 JSON
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"name":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("BindJSON valid status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"` // admin 或 staff
|
||||
UserType string `json:"user_type"` // super_admin, admin, staff
|
||||
JTI string `json:"jti"` // JWT ID(唯一标识,用于黑名单)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var (
|
||||
//ErrTokenExpired 令牌已过期
|
||||
ErrTokenExpired = errors.New("令牌已过期")
|
||||
//ErrTokenInvalid 令牌无效
|
||||
ErrTokenInvalid = errors.New("令牌无效")
|
||||
//ErrTokenMalformed 令牌格式错误
|
||||
ErrTokenMalformed = errors.New("令牌格式错误")
|
||||
//ErrTokenNotValidYet 令牌尚未生效
|
||||
ErrTokenNotValidYet = errors.New("令牌尚未生效")
|
||||
//ErrTokenRevoked 令牌已被撤销
|
||||
ErrTokenRevoked = errors.New("令牌已被撤销")
|
||||
)
|
||||
|
||||
// generateJTI 生成唯一的 JWT ID
|
||||
func generateJTI() string {
|
||||
bytes := make([]byte, 16)
|
||||
rand.Read(bytes)
|
||||
return base64.URLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// TokenBlacklist Token 黑名单管理(使用 JTI 优化)
|
||||
type TokenBlacklist struct{}
|
||||
|
||||
// Add 将 Token 的 JTI 加入黑名单
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI(32字节)替代完整 Token(数百字节),大幅节省 Redis 内存
|
||||
// 参数: jti JWT ID,expiry Token 过期时间
|
||||
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,跳过黑名单
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ttl := time.Until(expiry)
|
||||
if ttl <= 0 {
|
||||
// Token 已过期,无需加入黑名单
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用 JTI 作为键名(约24字节),而非完整 Token(数百字节)
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
return database.RedisClient.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,不检查黑名单
|
||||
return false
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0
|
||||
}
|
||||
|
||||
// 全局黑名单实例
|
||||
var tokenBlacklist = &TokenBlacklist{}
|
||||
|
||||
// GenerateToken 生成 JWT Token
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动生成 JTI,支持高效黑名单机制
|
||||
func GenerateToken(userID uint, username, role, userType string) (string, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
// 生成唯一的 JWT ID
|
||||
jti := generateJTI()
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWT.Expire) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
ID: jti, // 同时设置到 RegisteredClaims.ID
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
|
||||
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
jti := generateJTI()
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
UserType: userType,
|
||||
JTI: jti,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "xlgo",
|
||||
ID: jti,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI 检查黑名单,效率更高
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenMalformed) {
|
||||
return nil, ErrTokenMalformed
|
||||
}
|
||||
if errors.Is(err, jwt.ErrTokenNotValidYet) {
|
||||
return nil, ErrTokenNotValidYet
|
||||
}
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
// 使用 JTI 检查黑名单(更高效)
|
||||
if claims.JTI != "" && tokenBlacklist.IsBlacklisted(claims.JTI) {
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// InvalidateToken 使 Token 失效(加入黑名单)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 JTI 而非完整 Token,节省 Redis 内存
|
||||
func InvalidateToken(tokenString string) error {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Token 无效或已过期,无需加入黑名单
|
||||
return nil
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
return tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
|
||||
// 参数: jti JWT ID,expiry 过期时间
|
||||
func InvalidateTokenByID(jti string, expiry time.Time) error {
|
||||
return tokenBlacklist.Add(jti, expiry)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
func RefreshToken(tokenString string) (string, error) {
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 将旧 Token 加入黑名单
|
||||
if claims.JTI != "" && claims.ExpiresAt != nil {
|
||||
tokenBlacklist.Add(claims.JTI, claims.ExpiresAt.Time)
|
||||
}
|
||||
|
||||
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
|
||||
}
|
||||
|
||||
// GetJTI 从 Token 中提取 JTI(不验证签名)
|
||||
// 用于需要在验证前获取 JTI 的场景
|
||||
func GetJTI(tokenString string) (string, error) {
|
||||
token, _, err := jwt.NewParser().ParseUnverified(tokenString, &Claims{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
return claims.JTI, nil
|
||||
}
|
||||
|
||||
return "", ErrTokenInvalid
|
||||
}
|
||||
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
|
||||
func IsTokenRevoked(jti string) bool {
|
||||
return tokenBlacklist.IsBlacklisted(jti)
|
||||
}
|
||||
|
||||
// GetClaimsFromToken 获取 Token 的 Claims(不验证过期)
|
||||
// 用于获取已过期 Token 的信息
|
||||
func GetClaimsFromToken(tokenString string) (*Claims, error) {
|
||||
cfg := config.Get()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package jwt_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
// 设置测试配置
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-12345",
|
||||
Expire: 3600, // 1小时
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
}
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken error: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Error("GenerateToken should return non-empty token")
|
||||
}
|
||||
|
||||
// Token 应包含三部分(用 . 分隔)
|
||||
parts := splitToken(token)
|
||||
if len(parts) != 3 {
|
||||
t.Errorf("Token should have 3 parts, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
// 先生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
|
||||
// 解析 token
|
||||
claims, err := jwt.ParseToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken error: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != 1 {
|
||||
t.Errorf("UserID = %d, want 1", claims.UserID)
|
||||
}
|
||||
if claims.Username != "testuser" {
|
||||
t.Errorf("Username = %s, want testuser", claims.Username)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role = %s, want admin", claims.Role)
|
||||
}
|
||||
if claims.UserType != "super_admin" {
|
||||
t.Errorf("UserType = %s, want super_admin", claims.UserType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenInvalid(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
// 无效 token
|
||||
_, err := jwt.ParseToken("invalid-token")
|
||||
if err == nil {
|
||||
t.Error("ParseToken should fail with invalid token")
|
||||
}
|
||||
|
||||
// 空 token
|
||||
_, err = jwt.ParseToken("")
|
||||
if err == nil {
|
||||
t.Error("ParseToken should fail with empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenWrongSecret(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
// 用不同 secret 生成的 token
|
||||
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
|
||||
// 修改 secret
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "different-secret",
|
||||
Expire: 3600,
|
||||
},
|
||||
}
|
||||
config.Set(cfg)
|
||||
|
||||
// 应该解析失败
|
||||
_, err := jwt.ParseToken(token)
|
||||
if err == nil {
|
||||
t.Error("ParseToken should fail with wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
// 生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
|
||||
// 刷新 token
|
||||
newToken, err := jwt.RefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken error: %v", err)
|
||||
}
|
||||
|
||||
if newToken == "" {
|
||||
t.Error("RefreshToken should return non-empty token")
|
||||
}
|
||||
|
||||
// 新 token 应可解析
|
||||
claims, err := jwt.ParseToken(newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken new token error: %v", err)
|
||||
}
|
||||
|
||||
if claims.Username != "testuser" {
|
||||
t.Error("RefreshToken claims should match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimsStructure(t *testing.T) {
|
||||
claims := jwt.Claims{
|
||||
UserID: 1,
|
||||
Username: "test",
|
||||
Role: "admin",
|
||||
UserType: "super_admin",
|
||||
}
|
||||
|
||||
if claims.UserID != 1 {
|
||||
t.Error("Claims UserID failed")
|
||||
}
|
||||
if claims.Username != "test" {
|
||||
t.Error("Claims Username failed")
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Error("Claims Username failed")
|
||||
}
|
||||
if claims.UserType != "super_admin" {
|
||||
t.Error("Claims Username failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorDefinitions(t *testing.T) {
|
||||
if jwt.ErrTokenExpired == nil {
|
||||
t.Error("ErrTokenExpired should be defined")
|
||||
}
|
||||
if jwt.ErrTokenInvalid == nil {
|
||||
t.Error("ErrTokenInvalid should be defined")
|
||||
}
|
||||
if jwt.ErrTokenMalformed == nil {
|
||||
t.Error("ErrTokenMalformed should be defined")
|
||||
}
|
||||
if jwt.ErrTokenNotValidYet == nil {
|
||||
t.Error("ErrTokenNotValidYet should be defined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklist(t *testing.T) {
|
||||
tb := jwt.TokenBlacklist{}
|
||||
|
||||
// 无 Redis 时,Add 应返回 nil
|
||||
err := tb.Add("test-token", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Errorf("TokenBlacklist.Add without Redis should return nil, got %v", err)
|
||||
}
|
||||
|
||||
// 无 Redis 时,IsBlacklisted 应返回 false
|
||||
if tb.IsBlacklisted("test-token") {
|
||||
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
token, _ := jwt.GenerateToken(1, "test", "admin", "admin")
|
||||
|
||||
// 无 Redis 时应返回 nil
|
||||
err := jwt.InvalidateToken(token)
|
||||
if err != nil {
|
||||
t.Errorf("InvalidateToken without Redis should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func splitToken(token string) []string {
|
||||
count := 0
|
||||
for _, c := range token {
|
||||
if c == '.' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
start := 0
|
||||
result := make([]string, 0, 3)
|
||||
for i, c := range token {
|
||||
if c == '.' {
|
||||
result = append(result, token[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
result = append(result, token[start:])
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Field Zap 字段别名,用于简化日志字段定义
|
||||
var Field = struct {
|
||||
String func(key string, value string) zap.Field
|
||||
Int func(key string, value int) zap.Field
|
||||
Int64 func(key string, value int64) zap.Field
|
||||
Bool func(key string, value bool) zap.Field
|
||||
Uint func(key string, value uint) zap.Field
|
||||
Float64 func(key string, value float64) zap.Field
|
||||
Duration func(key string, value interface{}) zap.Field
|
||||
Error func(err error) zap.Field
|
||||
}{
|
||||
String: zap.String,
|
||||
Int: zap.Int,
|
||||
Int64: zap.Int64,
|
||||
Bool: zap.Bool,
|
||||
Uint: zap.Uint,
|
||||
Float64: zap.Float64,
|
||||
Duration: func(key string, value interface{}) zap.Field {
|
||||
switch v := value.(type) {
|
||||
case zap.Field:
|
||||
return v
|
||||
default:
|
||||
return zap.Any(key, value)
|
||||
}
|
||||
},
|
||||
Error: func(err error) zap.Field {
|
||||
return zap.Error(err)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
// Logger 全局日志实例
|
||||
Logger *zap.Logger
|
||||
sugar *zap.SugaredLogger
|
||||
apiLog *zap.Logger
|
||||
dbLog *zap.Logger
|
||||
)
|
||||
|
||||
// Init 初始化日志
|
||||
func Init(cfg *config.Config) error {
|
||||
// 确保日志目录存在
|
||||
if err := os.MkdirAll(cfg.Log.Dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 日志编码器配置
|
||||
encoderConfig := zapcore.EncoderConfig{
|
||||
TimeKey: "time",
|
||||
LevelKey: "level",
|
||||
NameKey: "logger",
|
||||
CallerKey: "caller",
|
||||
FunctionKey: zapcore.OmitKey,
|
||||
MessageKey: "msg",
|
||||
StacktraceKey: "stacktrace",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.LowercaseLevelEncoder,
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.SecondsDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
}
|
||||
|
||||
// 根据运行模式设置日志级别
|
||||
var level zapcore.Level
|
||||
if cfg.IsProduction() {
|
||||
level = zapcore.WarnLevel
|
||||
} else {
|
||||
level = zapcore.DebugLevel
|
||||
}
|
||||
|
||||
// API 日志
|
||||
apiWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(cfg.Log.Dir, "api.log"),
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
}
|
||||
|
||||
// 数据库日志
|
||||
dbWriter := &lumberjack.Logger{
|
||||
Filename: filepath.Join(cfg.Log.Dir, "database.log"),
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
Compress: cfg.Log.Compress,
|
||||
}
|
||||
|
||||
// 控制台输出
|
||||
consoleEncoder := zapcore.NewConsoleEncoder(encoderConfig)
|
||||
consoleWriter := zapcore.AddSync(os.Stdout)
|
||||
|
||||
// 创建核心
|
||||
apiCore := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(apiWriter),
|
||||
level,
|
||||
)
|
||||
|
||||
dbCore := zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(dbWriter),
|
||||
level,
|
||||
)
|
||||
|
||||
consoleCore := zapcore.NewCore(
|
||||
consoleEncoder,
|
||||
consoleWriter,
|
||||
level,
|
||||
)
|
||||
|
||||
// 合并核心
|
||||
core := zapcore.NewTee(apiCore, dbCore, consoleCore)
|
||||
|
||||
// 创建日志实例
|
||||
Logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
sugar = Logger.Sugar()
|
||||
|
||||
// 创建专用日志实例
|
||||
apiLog = zap.New(apiCore, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
dbLog = zap.New(dbCore, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync 同步日志
|
||||
func Sync() {
|
||||
if Logger != nil {
|
||||
_ = Logger.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
// Debug 调试日志
|
||||
func Debug(msg string, fields ...zap.Field) {
|
||||
Logger.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
// Info 信息日志
|
||||
func Info(msg string, fields ...zap.Field) {
|
||||
Logger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
// Warn 警告日志
|
||||
func Warn(msg string, fields ...zap.Field) {
|
||||
Logger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// Error 错误日志
|
||||
func Error(msg string, fields ...zap.Field) {
|
||||
Logger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
// Fatal 致命错误日志
|
||||
func Fatal(msg string, fields ...zap.Field) {
|
||||
Logger.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
// Debugf 格式化调试日志
|
||||
func Debugf(template string, args ...any) {
|
||||
sugar.Debugf(template, args...)
|
||||
}
|
||||
|
||||
// Infof 格式化信息日志
|
||||
func Infof(template string, args ...any) {
|
||||
sugar.Infof(template, args...)
|
||||
}
|
||||
|
||||
// Warnf 格式化警告日志
|
||||
func Warnf(template string, args ...any) {
|
||||
sugar.Warnf(template, args...)
|
||||
}
|
||||
|
||||
// Errorf 格式化错误日志
|
||||
func Errorf(template string, args ...any) {
|
||||
sugar.Errorf(template, args...)
|
||||
}
|
||||
|
||||
// Fatalf 格式化致命错误日志
|
||||
func Fatalf(template string, args ...any) {
|
||||
sugar.Fatalf(template, args...)
|
||||
}
|
||||
|
||||
// APILog API 专用日志
|
||||
func APILog() *zap.Logger {
|
||||
return apiLog
|
||||
}
|
||||
|
||||
// DBLog 数据库专用日志
|
||||
func DBLog() *zap.Logger {
|
||||
return dbLog
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
)
|
||||
|
||||
// 注意:logger 需要先初始化才能调用 Info/Debug/Warn/Error
|
||||
// 这里只测试 Field 函数,不测试日志输出
|
||||
|
||||
func TestAPILog(t *testing.T) {
|
||||
_ = logger.APILog()
|
||||
// 未初始化时为 nil,这是预期行为
|
||||
}
|
||||
|
||||
func TestDBLog(t *testing.T) {
|
||||
_ = logger.DBLog()
|
||||
// 未初始化时为 nil,这是预期行为
|
||||
}
|
||||
|
||||
func TestFieldString(t *testing.T) {
|
||||
field := logger.Field.String("key", "value")
|
||||
if field.Key != "key" {
|
||||
t.Error("Field.String key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldInt(t *testing.T) {
|
||||
field := logger.Field.Int("count", 123)
|
||||
if field.Key != "count" {
|
||||
t.Error("Field.Int key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldInt64(t *testing.T) {
|
||||
field := logger.Field.Int64("id", 1234567890)
|
||||
if field.Key != "id" {
|
||||
t.Error("Field.Int64 key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldBool(t *testing.T) {
|
||||
field := logger.Field.Bool("enabled", true)
|
||||
if field.Key != "enabled" {
|
||||
t.Error("Field.Bool key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldUint(t *testing.T) {
|
||||
field := logger.Field.Uint("uint_val", 100)
|
||||
if field.Key != "uint_val" {
|
||||
t.Error("Field.Uint key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldFloat64(t *testing.T) {
|
||||
field := logger.Field.Float64("price", 99.99)
|
||||
if field.Key != "price" {
|
||||
t.Error("Field.Float64 key failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldError(t *testing.T) {
|
||||
_ = logger.Field.Error(nil)
|
||||
// zap.Field 是结构体,仅验证函数调用正常
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// ContextKeyUserID 用户ID上下文键
|
||||
ContextKeyUserID = "user_id"
|
||||
// ContextKeyUsername 用户名上下文键
|
||||
ContextKeyUsername = "username"
|
||||
// ContextKeyRole 角色上下文键
|
||||
ContextKeyRole = "role"
|
||||
// ContextKeyUserType 用户类型上下文键
|
||||
ContextKeyUserType = "user_type"
|
||||
)
|
||||
|
||||
// AuthRequired JWT 认证中间件
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "登录已过期,请重新登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文
|
||||
c.Set(ContextKeyUserID, claims.UserID)
|
||||
c.Set(ContextKeyUsername, claims.Username)
|
||||
c.Set(ContextKeyRole, claims.Role)
|
||||
c.Set(ContextKeyUserType, claims.UserType)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired 管理员权限中间件
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "super_admin" && ut != "admin" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// SuperAdminRequired 超级管理员权限中间件
|
||||
func SuperAdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "super_admin" {
|
||||
response.Fail(c, "需要超级管理员权限")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StaffRequired 员工权限中间件
|
||||
func StaffRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if ut != "staff" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AnyUserRequired 任意用户权限中间件(允许 admin/super_admin/staff)
|
||||
func AnyUserRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "用户信息异常")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 允许所有内部用户类型
|
||||
if ut != "super_admin" && ut != "admin" && ut != "staff" {
|
||||
response.Fail(c, "无权限访问")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从上下文获取用户ID
|
||||
func GetUserID(c *gin.Context) uint {
|
||||
userID, exists := c.Get(ContextKeyUserID)
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
// 安全的类型断言
|
||||
id, ok := userID.(uint)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// GetUsername 从上下文获取用户名
|
||||
func GetUsername(c *gin.Context) string {
|
||||
username, exists := c.Get(ContextKeyUsername)
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
// 安全的类型断言
|
||||
name, ok := username.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// GetUserType 从上下文获取用户类型
|
||||
func GetUserType(c *gin.Context) string {
|
||||
userType, exists := c.Get(ContextKeyUserType)
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
// 安全的类型断言
|
||||
ut, ok := userType.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return ut
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 支持从配置文件读取 CORS 配置,生产环境更安全
|
||||
func CORS() gin.HandlerFunc {
|
||||
return CORSWithConfig(nil)
|
||||
}
|
||||
|
||||
// CORSWithConfig 使用自定义配置的 CORS 中间件
|
||||
func CORSWithConfig(corsCfg *config.CORSConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
|
||||
// 获取配置
|
||||
cfg := config.Get()
|
||||
var corsConfig *config.CORSConfig
|
||||
if corsCfg != nil {
|
||||
corsConfig = corsCfg
|
||||
} else if cfg != nil {
|
||||
corsConfig = &cfg.CORS
|
||||
}
|
||||
|
||||
// 获取允许的域名列表
|
||||
allowedOrigins := getAllowedOrigins(cfg, corsConfig)
|
||||
|
||||
// 检查是否在白名单中
|
||||
allowedOrigin := ""
|
||||
if origin != "" {
|
||||
for _, ao := range allowedOrigins {
|
||||
if ao == "*" {
|
||||
allowedOrigin = "*"
|
||||
break
|
||||
}
|
||||
// 支持通配符匹配(如 *.example.com)
|
||||
if strings.HasPrefix(ao, "*.") {
|
||||
domain := ao[2:]
|
||||
if strings.HasSuffix(origin, domain) {
|
||||
allowedOrigin = origin
|
||||
break
|
||||
}
|
||||
}
|
||||
if ao == origin {
|
||||
allowedOrigin = origin
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到白名单
|
||||
if allowedOrigin == "" {
|
||||
// 开发环境允许所有来源
|
||||
if cfg != nil && cfg.IsDevelopment() {
|
||||
allowedOrigin = "*"
|
||||
} else if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
|
||||
// 配置了通配符
|
||||
allowedOrigin = "*"
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 CORS 响应头
|
||||
if allowedOrigin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
}
|
||||
|
||||
// 从配置获取允许的方法、请求头等
|
||||
methods := corsConfig.GetAllowedMethods()
|
||||
headers := corsConfig.GetAllowedHeaders()
|
||||
exposedHeaders := corsConfig.GetExposedHeaders()
|
||||
maxAge := corsConfig.GetMaxAge()
|
||||
|
||||
c.Header("Access-Control-Allow-Methods", strings.Join(methods, ", "))
|
||||
c.Header("Access-Control-Allow-Headers", strings.Join(headers, ", "))
|
||||
c.Header("Access-Control-Expose-Headers", strings.Join(exposedHeaders, ", "))
|
||||
c.Header("Access-Control-Max-Age", strconv.Itoa(maxAge))
|
||||
|
||||
// 是否允许携带凭证
|
||||
if corsConfig != nil && corsConfig.AllowCredentials {
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Credentials", "true") // 默认允许
|
||||
}
|
||||
|
||||
// 处理预检请求
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// getAllowedOrigins 获取允许的域名列表
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 优先使用配置文件,其次使用环境变量,最后使用默认值
|
||||
func getAllowedOrigins(cfg *config.Config, corsConfig *config.CORSConfig) []string {
|
||||
// 优先使用 CORS 配置
|
||||
if corsConfig != nil && len(corsConfig.AllowedOrigins) > 0 {
|
||||
return corsConfig.AllowedOrigins
|
||||
}
|
||||
|
||||
// 生产环境:必须配置具体的域名
|
||||
if cfg != nil && cfg.IsProduction() {
|
||||
// 返回空列表,生产环境必须配置
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 开发环境允许 localhost
|
||||
return []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:4200",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:4200",
|
||||
}
|
||||
}
|
||||
|
||||
// CORSWithOrigins 使用指定域名列表的 CORS 中间件
|
||||
func CORSWithOrigins(origins []string) gin.HandlerFunc {
|
||||
return CORSWithConfig(&config.CORSConfig{
|
||||
AllowedOrigins: origins,
|
||||
})
|
||||
}
|
||||
|
||||
// CORSWithWildcard 允许所有来源的 CORS 中间件(仅用于开发环境)
|
||||
// 注意:生产环境不建议使用
|
||||
func CORSWithWildcard() gin.HandlerFunc {
|
||||
return CORSWithConfig(&config.CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
})
|
||||
}
|
||||
|
||||
// CORSForAPI 适用于 API 的 CORS 中间件
|
||||
func CORSForAPI() gin.HandlerFunc {
|
||||
return CORSWithConfig(&config.CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
|
||||
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Requested-With"},
|
||||
AllowCredentials: false, // API 模式不允许凭证
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// CSRFTokenLength CSRF Token 长度
|
||||
CSRFTokenLength = 32
|
||||
// CSRFHeaderName CSRF Header 名称
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
// CSRFCookieName CSRF Cookie 名称
|
||||
CSRFCookieName = "csrf_token"
|
||||
// CSRFFormField 表单字段名称
|
||||
CSRFFormField = "_csrf"
|
||||
)
|
||||
|
||||
// CSRFConfig CSRF 配置
|
||||
type CSRFConfig struct {
|
||||
// TokenLength Token 长度
|
||||
TokenLength int
|
||||
// HeaderName Header 名称
|
||||
HeaderName string
|
||||
// CookieName Cookie 名称
|
||||
CookieName string
|
||||
// FormField 表单字段名
|
||||
FormField string
|
||||
// Secure Cookie 是否启用 Secure
|
||||
Secure bool
|
||||
// HTTPOnly Cookie 是否启用 HttpOnly
|
||||
HTTPOnly bool
|
||||
// SameSite Cookie SameSite 属性
|
||||
SameSite http.SameSite
|
||||
// Domain Cookie 域名
|
||||
Domain string
|
||||
// Path Cookie 路径
|
||||
Path string
|
||||
// MaxAge Cookie 有效期(秒)
|
||||
MaxAge int
|
||||
// ErrorFunc 错误处理函数
|
||||
ErrorFunc func(c *gin.Context)
|
||||
// SkipFunc 跳过检查函数
|
||||
SkipFunc func(c *gin.Context) bool
|
||||
}
|
||||
|
||||
// DefaultCSRFConfig 默认 CSRF 配置
|
||||
var DefaultCSRFConfig = CSRFConfig{
|
||||
TokenLength: CSRFTokenLength,
|
||||
HeaderName: CSRFHeaderName,
|
||||
CookieName: CSRFCookieName,
|
||||
FormField: CSRFFormField,
|
||||
Secure: false,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
MaxAge: 3600, // 1 小时
|
||||
ErrorFunc: defaultCSRFError,
|
||||
SkipFunc: nil,
|
||||
}
|
||||
|
||||
// generateCSRFToken 生成 CSRF Token
|
||||
func generateCSRFToken(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// defaultCSRFError 默认错误处理
|
||||
func defaultCSRFError(c *gin.Context) {
|
||||
response.Fail(c, "CSRF Token 无效,请刷新页面重试")
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
// CSRF 创建 CSRF 中间件
|
||||
func CSRF(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if cfg.TokenLength == 0 {
|
||||
cfg.TokenLength = CSRFTokenLength
|
||||
}
|
||||
if cfg.HeaderName == "" {
|
||||
cfg.HeaderName = CSRFHeaderName
|
||||
}
|
||||
if cfg.CookieName == "" {
|
||||
cfg.CookieName = CSRFCookieName
|
||||
}
|
||||
if cfg.ErrorFunc == nil {
|
||||
cfg.ErrorFunc = defaultCSRFError
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否跳过
|
||||
if cfg.SkipFunc != nil && cfg.SkipFunc(c) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取或生成 Token
|
||||
cookieToken, err := c.Cookie(cfg.CookieName)
|
||||
if err != nil || cookieToken == "" {
|
||||
// 生成新 Token
|
||||
token, err := generateCSRFToken(cfg.TokenLength)
|
||||
if err != nil {
|
||||
cfg.ErrorFunc(c)
|
||||
return
|
||||
}
|
||||
cookieToken = token
|
||||
|
||||
// 设置 Cookie
|
||||
c.SetCookie(
|
||||
cfg.CookieName,
|
||||
token,
|
||||
cfg.MaxAge,
|
||||
cfg.Path,
|
||||
cfg.Domain,
|
||||
cfg.Secure,
|
||||
cfg.HTTPOnly,
|
||||
)
|
||||
}
|
||||
|
||||
// 将 Token 存入上下文,供前端使用
|
||||
c.Set("csrf_token", cookieToken)
|
||||
|
||||
// 安全方法(GET, HEAD, OPTIONS, TRACE)不需要验证
|
||||
if isSafeMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Token
|
||||
clientToken := ""
|
||||
|
||||
// 优先从 Header 获取
|
||||
clientToken = c.GetHeader(cfg.HeaderName)
|
||||
|
||||
// 其次从表单获取
|
||||
if clientToken == "" {
|
||||
clientToken = c.PostForm(cfg.FormField)
|
||||
}
|
||||
|
||||
// 最后从 JSON body 获取
|
||||
if clientToken == "" {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
if token, ok := body[cfg.FormField].(string); ok {
|
||||
clientToken = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 Token 是否匹配
|
||||
if clientToken == "" || clientToken != cookieToken {
|
||||
cfg.ErrorFunc(c)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isSafeMethod 判断是否为安全方法
|
||||
func isSafeMethod(method string) bool {
|
||||
switch strings.ToUpper(method) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetCSRFToken 从上下文获取 CSRF Token
|
||||
func GetCSRFToken(c *gin.Context) string {
|
||||
token, exists := c.Get("csrf_token")
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
return token.(string)
|
||||
}
|
||||
|
||||
// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式)
|
||||
func CSRFToken(c *gin.Context) {
|
||||
token := GetCSRFToken(c)
|
||||
if token == "" {
|
||||
var err error
|
||||
token, err = generateCSRFToken(CSRFTokenLength)
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成 Token 失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"csrf_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// CSRFWithSkip 跳过指定路径的 CSRF 检查
|
||||
func CSRFWithSkip(skipPaths []string) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
path := c.Request.URL.Path
|
||||
for _, p := range skipPaths {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return CSRF(cfg)
|
||||
}
|
||||
|
||||
// CSRFForAPI 适用于 API 的 CSRF 中间件(不使用 Cookie)
|
||||
// 客户端需要先调用 /csrf-token 获取 Token
|
||||
func CSRFForAPI() gin.HandlerFunc {
|
||||
tokens := make(map[string]bool)
|
||||
var mu sync.RWMutex
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// 安全方法不需要验证
|
||||
if isSafeMethod(c.Request.Method) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 从 Header 获取 Token
|
||||
clientToken := c.GetHeader(CSRFHeaderName)
|
||||
if clientToken == "" {
|
||||
response.Fail(c, "缺少 CSRF Token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Token
|
||||
mu.RLock()
|
||||
valid := tokens[clientToken]
|
||||
mu.RUnlock()
|
||||
|
||||
if !valid {
|
||||
response.Fail(c, "CSRF Token 无效")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAPIToken 生成 API CSRF Token(用于 API 模式)
|
||||
func GenerateAPIToken(c *gin.Context) {
|
||||
token, err := generateCSRFToken(CSRFTokenLength)
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成 Token 失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 存储 Token(实际应用中应使用 Redis)
|
||||
// 这里简化为内存存储
|
||||
mu.Lock()
|
||||
tokens[token] = true
|
||||
mu.Unlock()
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"csrf_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// 内存存储(用于 API 模式)
|
||||
var (
|
||||
tokens = make(map[string]bool)
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// CSRFExempt 标记路由不需要 CSRF 保护
|
||||
// 使用方法:在路由组上使用此中间件
|
||||
func CSRFExempt() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("csrf_exempt", true)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFWithExempt 支持 exempt 标记的 CSRF 中间件
|
||||
func CSRFWithExempt(config ...CSRFConfig) gin.HandlerFunc {
|
||||
cfg := DefaultCSRFConfig
|
||||
if len(config) > 0 {
|
||||
cfg = config[0]
|
||||
}
|
||||
|
||||
originalSkipFunc := cfg.SkipFunc
|
||||
cfg.SkipFunc = func(c *gin.Context) bool {
|
||||
// 检查是否标记为 exempt
|
||||
if exempt, exists := c.Get("csrf_exempt"); exists && exempt.(bool) {
|
||||
return true
|
||||
}
|
||||
// 调用原始 SkipFunc
|
||||
if originalSkipFunc != nil {
|
||||
return originalSkipFunc(c)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return CSRF(cfg)
|
||||
}
|
||||
|
||||
// DoubleSubmitCookie 双重提交 Cookie 模式(无需服务器存储)
|
||||
// 适用于无状态 API
|
||||
func DoubleSubmitCookie() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 安全方法不需要验证
|
||||
if isSafeMethod(c.Request.Method) {
|
||||
// 设置 Cookie(如果不存在)
|
||||
cookieToken, err := c.Cookie(CSRFCookieName)
|
||||
if err != nil || cookieToken == "" {
|
||||
token, _ := generateCSRFToken(CSRFTokenLength)
|
||||
c.SetCookie(CSRFCookieName, token, 3600, "/", "", false, true)
|
||||
c.Set("csrf_token", token)
|
||||
} else {
|
||||
c.Set("csrf_token", cookieToken)
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 Cookie 中的 Token
|
||||
cookieToken, err := c.Cookie(CSRFCookieName)
|
||||
if err != nil {
|
||||
response.Fail(c, "CSRF Token 缺失")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 Header 中的 Token
|
||||
headerToken := c.GetHeader(CSRFHeaderName)
|
||||
if headerToken == "" {
|
||||
response.Fail(c, "缺少 CSRF Token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Cookie 和 Header 中的 Token 是否一致
|
||||
if cookieToken != headerToken {
|
||||
response.Fail(c, "CSRF Token 不匹配")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LoggerConfig 日志中间件配置
|
||||
type LoggerConfig struct {
|
||||
// LogRequestBody 是否记录请求体
|
||||
LogRequestBody bool
|
||||
// LogResponseBody 是否记录响应体
|
||||
LogResponseBody bool
|
||||
// MaxBodyLength 最大记录的请求/响应体长度(字节)
|
||||
MaxBodyLength int
|
||||
// SkipPaths 不记录日志的路径
|
||||
SkipPaths []string
|
||||
// SkipPathPrefixes 不记录日志的路径前缀
|
||||
SkipPathPrefixes []string
|
||||
// SlowRequestThreshold 慢请求阈值(超过此时间记录警告)
|
||||
SlowRequestThreshold time.Duration
|
||||
}
|
||||
|
||||
// DefaultLoggerConfig 默认日志配置
|
||||
var DefaultLoggerConfig = LoggerConfig{
|
||||
LogRequestBody: false, // 默认不记录请求体(敏感信息风险)
|
||||
LogResponseBody: false, // 默认不记录响应体
|
||||
MaxBodyLength: 1024, // 最大 1KB
|
||||
SkipPaths: []string{"/health", "/swagger"},
|
||||
SkipPathPrefixes: []string{"/public", "/static"},
|
||||
SlowRequestThreshold: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
// Logger 日志中间件(使用默认配置)
|
||||
func Logger() gin.HandlerFunc {
|
||||
return LoggerWithConfig(DefaultLoggerConfig)
|
||||
}
|
||||
|
||||
// LoggerWithConfig 使用自定义配置的日志中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 可配置是否记录请求体/响应体,跳过静态资源路径,慢请求警告
|
||||
func LoggerWithConfig(cfg LoggerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 检查是否跳过此路径
|
||||
path := c.Request.URL.Path
|
||||
if shouldSkipPath(path, cfg) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// 记录请求体(可选)
|
||||
var requestBody []byte
|
||||
if cfg.LogRequestBody && c.Request.Body != nil {
|
||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||
// 恢复请求体供后续处理
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
||||
// 限制记录长度
|
||||
if len(requestBody) > cfg.MaxBodyLength {
|
||||
requestBody = requestBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
}
|
||||
|
||||
// 记录响应体(可选)
|
||||
var responseBody []byte
|
||||
if cfg.LogResponseBody {
|
||||
// 使用 ResponseWriter 包装器捕获响应体
|
||||
blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
|
||||
c.Writer = blw
|
||||
c.Next()
|
||||
responseBody = blw.body.Bytes()
|
||||
if len(responseBody) > cfg.MaxBodyLength {
|
||||
responseBody = responseBody[:cfg.MaxBodyLength]
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
latency := time.Since(start)
|
||||
|
||||
// 构建日志字段
|
||||
fields := []zap.Field{
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", c.Request.URL.RawQuery),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Duration("latency", latency),
|
||||
zap.String("user-agent", c.Request.UserAgent()),
|
||||
zap.Int("body_size", c.Writer.Size()),
|
||||
zap.String("request_id", GetRequestID(c)),
|
||||
}
|
||||
|
||||
// 添加请求体
|
||||
if cfg.LogRequestBody && len(requestBody) > 0 {
|
||||
// 过滤敏感字段
|
||||
safeBody := filterSensitiveFields(requestBody)
|
||||
fields = append(fields, zap.String("request_body", safeBody))
|
||||
}
|
||||
|
||||
// 添加响应体
|
||||
if cfg.LogResponseBody && len(responseBody) > 0 {
|
||||
fields = append(fields, zap.String("response_body", string(responseBody)))
|
||||
}
|
||||
|
||||
// 添加用户信息(如果已登录)
|
||||
userID := GetUserID(c)
|
||||
if userID > 0 {
|
||||
fields = append(fields,
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("username", GetUsername(c)),
|
||||
zap.String("user_type", GetUserType(c)),
|
||||
)
|
||||
}
|
||||
|
||||
// 根据状态码和延迟选择日志级别
|
||||
status := c.Writer.Status()
|
||||
|
||||
if latency > cfg.SlowRequestThreshold {
|
||||
// 慢请求警告
|
||||
fields = append(fields, zap.Bool("slow_request", true))
|
||||
logger.APILog().Warn("Slow API Request", fields...)
|
||||
} else if status >= 500 {
|
||||
logger.APILog().Error("API Request Error", fields...)
|
||||
} else if status >= 400 {
|
||||
logger.APILog().Warn("API Request Client Error", fields...)
|
||||
} else {
|
||||
logger.APILog().Info("API Request", fields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLogWriter 响应体记录包装器
|
||||
type bodyLogWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
// Write 捕获响应体
|
||||
func (w *bodyLogWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// WriteString 捕获字符串响应
|
||||
func (w *bodyLogWriter) WriteString(s string) (int, error) {
|
||||
w.body.WriteString(s)
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
// shouldSkipPath 检查是否跳过路径
|
||||
func shouldSkipPath(path string, cfg LoggerConfig) bool {
|
||||
// 检查完整路径
|
||||
for _, p := range cfg.SkipPaths {
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查路径前缀
|
||||
for _, prefix := range cfg.SkipPathPrefixes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// filterSensitiveFields 过滤敏感字段(密码、token等)
|
||||
func filterSensitiveFields(body []byte) string {
|
||||
bodyStr := string(body)
|
||||
|
||||
// 过滤常见敏感字段(简单字符串替换)
|
||||
sensitiveFields := []string{
|
||||
"password", "passwd", "pwd",
|
||||
"token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "apikey",
|
||||
"credit_card", "card_number",
|
||||
}
|
||||
|
||||
for _, field := range sensitiveFields {
|
||||
// 检查是否包含敏感字段
|
||||
keyPattern := `"` + field + `":`
|
||||
if strings.Contains(bodyStr, keyPattern) {
|
||||
// 简单替换:将值替换为 [FILTERED]
|
||||
// 注意:这是一个简化的实现,复杂的 JSON 可能需要更精确的处理
|
||||
bodyStr = strings.ReplaceAll(bodyStr, keyPattern, keyPattern+"\"[FILTERED]\"")
|
||||
}
|
||||
}
|
||||
|
||||
return bodyStr
|
||||
}
|
||||
|
||||
// LoggerForAPI API 专用日志中间件(更详细)
|
||||
func LoggerForAPI() gin.HandlerFunc {
|
||||
cfg := DefaultLoggerConfig
|
||||
cfg.LogRequestBody = true
|
||||
cfg.LogResponseBody = false // 响应体通常较大
|
||||
return LoggerWithConfig(cfg)
|
||||
}
|
||||
|
||||
// LoggerForDebug 调试专用日志中间件(最详细)
|
||||
func LoggerForDebug() gin.HandlerFunc {
|
||||
cfg := LoggerConfig{
|
||||
LogRequestBody: true,
|
||||
LogResponseBody: true,
|
||||
MaxBodyLength: 4096, // 4KB
|
||||
SkipPaths: []string{"/health"},
|
||||
SkipPathPrefixes: []string{},
|
||||
SlowRequestThreshold: 200 * time.Millisecond,
|
||||
}
|
||||
return LoggerWithConfig(cfg)
|
||||
}
|
||||
|
||||
// LoggerMinimal 最简日志中间件(只记录基本信息)
|
||||
func LoggerMinimal() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 跳过健康检查和静态资源
|
||||
if path == "/health" || strings.HasPrefix(path, "/public") || strings.HasPrefix(path, "/static") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
logger.APILog().Info("Request",
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Duration("latency", time.Since(start)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
return r
|
||||
}
|
||||
|
||||
// ===== RequestID Tests =====
|
||||
|
||||
func TestRequestID(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RequestID())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
id := middleware.GetRequestID(c)
|
||||
c.JSON(200, gin.H{"request_id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证响应头有 X-Request-ID
|
||||
headerID := w.Header().Get("X-Request-ID")
|
||||
if headerID == "" {
|
||||
t.Error("X-Request-ID header should be set")
|
||||
}
|
||||
|
||||
// 验证响应体中的 request_id
|
||||
if w.Code != 200 {
|
||||
t.Errorf("RequestID status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDWithExisting(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RequestID())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
id := middleware.GetRequestID(c)
|
||||
c.JSON(200, gin.H{"request_id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Request-ID", "custom-id-123")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证使用了传入的 ID
|
||||
headerID := w.Header().Get("X-Request-ID")
|
||||
if headerID != "custom-id-123" {
|
||||
t.Errorf("X-Request-ID = %s, want custom-id-123", headerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequestIDEmpty(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
id := middleware.GetRequestID(c)
|
||||
c.JSON(200, gin.H{"request_id": id})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 没有 RequestID 中间件时,返回空
|
||||
if w.Code != 200 {
|
||||
t.Errorf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Recover Tests =====
|
||||
|
||||
func TestRecover(t *testing.T) {
|
||||
// 需要初始化 logger,否则 Recover 会 panic
|
||||
// 这里跳过完整测试,仅验证中间件可以正常注册
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.Recover())
|
||||
r.GET("/normal", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/normal", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Normal status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverNoPanic(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.Recover())
|
||||
r.GET("/normal", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/normal", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Normal status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CSRF Tests =====
|
||||
|
||||
func TestCSRF(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CSRF())
|
||||
r.GET("/form", func(c *gin.Context) {
|
||||
token := middleware.GetCSRFToken(c)
|
||||
c.JSON(200, gin.H{"csrf_token": token})
|
||||
})
|
||||
r.POST("/submit", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET 请求应该成功,并设置 cookie
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/form", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("CSRF GET status = %d", w.Code)
|
||||
}
|
||||
|
||||
// 获取 cookie 中的 token
|
||||
cookies := w.Result().Cookies()
|
||||
var csrfToken string
|
||||
for _, c := range cookies {
|
||||
if c.Name == "csrf_token" {
|
||||
csrfToken = c.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// POST 无 token 应该失败
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/submit", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != 200 {
|
||||
// 成功捕获错误,返回 200 但 code != 1
|
||||
}
|
||||
|
||||
// POST 带 token 应该成功
|
||||
w3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest("POST", "/submit", nil)
|
||||
req3.Header.Set("X-CSRF-Token", csrfToken)
|
||||
r.ServeHTTP(w3, req3)
|
||||
|
||||
// 注意:由于 cookie 需要从上一个请求传递,这里可能需要手动设置
|
||||
}
|
||||
|
||||
func TestGetCSRFToken(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CSRF())
|
||||
r.GET("/token", func(c *gin.Context) {
|
||||
token := middleware.GetCSRFToken(c)
|
||||
c.JSON(200, gin.H{"token": token})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/token", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("GetCSRFToken status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoubleSubmitCookie(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.DoubleSubmitCookie())
|
||||
r.GET("/get", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
r.POST("/post", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// GET 应该成功
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/get", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("DoubleSubmit GET status = %d", w.Code)
|
||||
}
|
||||
|
||||
// POST 无 token 应该失败
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/post", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
|
||||
// 应返回错误
|
||||
if w2.Code != 200 && w2.Code != 400 {
|
||||
t.Errorf("DoubleSubmit POST without token status = %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CORS Tests =====
|
||||
|
||||
func TestCORS(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CORS())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 使用 localhost origin(开发环境默认允许)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Origin", "http://localhost:3000")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证请求成功
|
||||
if w.Code != 200 {
|
||||
t.Errorf("CORS status = %d", w.Code)
|
||||
}
|
||||
|
||||
// 验证其他 CORS 头始终设置
|
||||
allowMethods := w.Header().Get("Access-Control-Allow-Methods")
|
||||
if allowMethods == "" {
|
||||
t.Error("Access-Control-Allow-Methods should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSOptions(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CORS())
|
||||
r.POST("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// OPTIONS 预检请求
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("OPTIONS", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// OPTIONS 应返回 204
|
||||
if w.Code != 204 {
|
||||
t.Errorf("CORS OPTIONS status = %d, want 204", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RateLimit Tests =====
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
// 使用自定义限流器
|
||||
limiter := middleware.NewRateLimiter(10, time.Minute) // 每分钟10次
|
||||
defer limiter.Stop()
|
||||
r.Use(middleware.RateLimit(limiter))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 正常请求应该成功
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 单次请求应该成功
|
||||
if w.Code != 200 {
|
||||
t.Errorf("RateLimit status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
limiter := middleware.NewRateLimiter(3, time.Minute) // 每分钟3次
|
||||
defer limiter.Stop()
|
||||
|
||||
// 前3次允许
|
||||
for i := 0; i < 3; i++ {
|
||||
if !limiter.Allow("192.168.1.1") {
|
||||
t.Errorf("Request %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 第4次拒绝
|
||||
if limiter.Allow("192.168.1.1") {
|
||||
t.Error("Request 4 should be denied")
|
||||
}
|
||||
|
||||
// 不同 IP 应该独立计数
|
||||
if !limiter.Allow("192.168.1.2") {
|
||||
t.Error("Different IP should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CustomRateLimit(5, time.Minute))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("CustomRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
middleware.InitRateLimiters()
|
||||
defer middleware.StopRateLimiters()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.LoginRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("LoginRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRateLimit(t *testing.T) {
|
||||
middleware.InitRateLimiters()
|
||||
defer middleware.StopRateLimiters()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.APIRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("APIRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRateLimit(t *testing.T) {
|
||||
middleware.InitRateLimiters()
|
||||
defer middleware.StopRateLimiters()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.UploadRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("UploadRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== RedisRateLimiter Tests =====
|
||||
|
||||
func TestRedisRateLimiter(t *testing.T) {
|
||||
limiter := middleware.NewRedisRateLimiter("test_limit", 10, time.Minute)
|
||||
|
||||
// Without Redis, should always allow
|
||||
ctx := context.Background()
|
||||
allowed, err := limiter.Allow(ctx, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Errorf("RedisRateLimiter error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Error("RedisRateLimiter should allow without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimitMiddleware(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimit("test_api", 100))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Without Redis, should pass
|
||||
if w.Code != 200 {
|
||||
t.Errorf("RedisRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRedisRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.LoginRedisRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("LoginRedisRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIRedisRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.APIRedisRateLimit())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("APIRedisRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRedisRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("CustomRedisRateLimit status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimiterGetCount(t *testing.T) {
|
||||
limiter := middleware.NewRedisRateLimiter("test_count", 10, time.Minute)
|
||||
|
||||
ctx := context.Background()
|
||||
count, err := limiter.GetCount(ctx, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Errorf("GetCount error: %v", err)
|
||||
}
|
||||
// Without Redis, count should be 0
|
||||
if count != 0 {
|
||||
t.Errorf("GetCount = %d, want 0 without Redis", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRateLimiterReset(t *testing.T) {
|
||||
limiter := middleware.NewRedisRateLimiter("test_reset", 10, time.Minute)
|
||||
|
||||
ctx := context.Background()
|
||||
err := limiter.Reset(ctx, "192.168.1.1")
|
||||
if err != nil {
|
||||
t.Errorf("Reset error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimiter 速率限制器(内存版,单实例使用)
|
||||
type RateLimiter struct {
|
||||
visitors map[string]*visitor
|
||||
mu sync.RWMutex
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
lastSeen time.Time
|
||||
count int
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建速率限制器(内存版)
|
||||
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
limiter := &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
rate: rate,
|
||||
window: window,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
limiter.wg.Add(1)
|
||||
go limiter.cleanupVisitors()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
v, exists := rl.visitors[ip]
|
||||
if !exists {
|
||||
rl.visitors[ip] = &visitor{
|
||||
lastSeen: time.Now(),
|
||||
count: 1,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if time.Since(v.lastSeen) > rl.window {
|
||||
v.count = 1
|
||||
v.lastSeen = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
if v.count >= rl.rate {
|
||||
return false
|
||||
}
|
||||
|
||||
v.count++
|
||||
v.lastSeen = time.Now()
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupVisitors 清理过期的访问者记录
|
||||
func (rl *RateLimiter) cleanupVisitors() {
|
||||
defer rl.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-rl.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rl.mu.Lock()
|
||||
for ip, v := range rl.visitors {
|
||||
if time.Since(v.lastSeen) > rl.window {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止限流器(释放资源)
|
||||
func (rl *RateLimiter) Stop() {
|
||||
rl.cancel()
|
||||
rl.wg.Wait()
|
||||
}
|
||||
|
||||
// ===== Redis 分布式限流器 =====
|
||||
|
||||
// RedisRateLimiter Redis 分布式限流器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多实例部署时限流共享,使用滑动窗口算法
|
||||
type RedisRateLimiter struct {
|
||||
keyPrefix string // 键名前缀
|
||||
rate int // 每分钟允许的请求数
|
||||
window time.Duration // 时间窗口
|
||||
}
|
||||
|
||||
// slidingWindowLua 滑动窗口限流 Lua 脚本
|
||||
// 返回: 当前窗口内的请求数
|
||||
const slidingWindowLua = `
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
local rate = tonumber(ARGV[3])
|
||||
|
||||
-- 移除窗口外的旧记录
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
|
||||
|
||||
-- 获取当前窗口内的请求数
|
||||
local count = redis.call('ZCARD', key)
|
||||
|
||||
if count < rate then
|
||||
-- 添加当前请求
|
||||
redis.call('ZADD', key, now, now .. '-' .. math.random())
|
||||
redis.call('PEXPIRE', key, window)
|
||||
return 0
|
||||
else
|
||||
return count
|
||||
end
|
||||
`
|
||||
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
return &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用滑动窗口算法,精确限流
|
||||
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
// Redis 未启用,默认允许
|
||||
return true, nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
now := float64(time.Now().UnixMilli())
|
||||
windowMs := float64(rl.window.Milliseconds())
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
|
||||
if err != nil {
|
||||
return true, err // 出错时允许请求,避免影响业务
|
||||
}
|
||||
|
||||
count := result.(int64)
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
// GetCount 获取当前窗口内的请求数
|
||||
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
now := time.Now().UnixMilli()
|
||||
windowStart := now - rl.window.Milliseconds()
|
||||
|
||||
// 移除旧记录并获取当前计数
|
||||
database.RedisClient.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
||||
return database.RedisClient.ZCard(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Reset 重置限流计数
|
||||
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := rl.keyPrefix + ":" + identifier
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 全局限速器 =====
|
||||
|
||||
var (
|
||||
loginLimiter *RateLimiter
|
||||
apiLimiter *RateLimiter
|
||||
uploadLimiter *RateLimiter
|
||||
redisLimiters map[string]*RedisRateLimiter
|
||||
limitersMu sync.Mutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
redisLimiters = make(map[string]*RedisRateLimiter)
|
||||
}
|
||||
|
||||
// InitRateLimiters 初始化限速器
|
||||
func InitRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
|
||||
// 先停止旧的限流器
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
}
|
||||
|
||||
// 内存限流器(单实例)
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
}
|
||||
|
||||
// StopRateLimiters 停止所有限速器(应用关闭时调用)
|
||||
func StopRateLimiters() {
|
||||
limitersMu.Lock()
|
||||
defer limitersMu.Unlock()
|
||||
|
||||
if loginLimiter != nil {
|
||||
loginLimiter.Stop()
|
||||
loginLimiter = nil
|
||||
}
|
||||
if apiLimiter != nil {
|
||||
apiLimiter.Stop()
|
||||
apiLimiter = nil
|
||||
}
|
||||
if uploadLimiter != nil {
|
||||
uploadLimiter.Stop()
|
||||
uploadLimiter = nil
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit 通用速率限制中间件(内存版)
|
||||
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !limiter.Allow(ip) {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRateLimit 登录接口速率限制
|
||||
func LoginRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if loginLimiter == nil {
|
||||
loginLimiter = NewRateLimiter(10, time.Minute)
|
||||
}
|
||||
limiter := loginLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// APIRateLimit 普通 API 速率限制
|
||||
func APIRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if apiLimiter == nil {
|
||||
apiLimiter = NewRateLimiter(100, time.Minute)
|
||||
}
|
||||
limiter := apiLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// UploadRateLimit 上传接口速率限制
|
||||
func UploadRateLimit() gin.HandlerFunc {
|
||||
limitersMu.Lock()
|
||||
if uploadLimiter == nil {
|
||||
uploadLimiter = NewRateLimiter(20, time.Minute)
|
||||
}
|
||||
limiter := uploadLimiter
|
||||
limitersMu.Unlock()
|
||||
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// CustomRateLimit 自定义速率限制(内存版)
|
||||
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(rate, window)
|
||||
return RateLimit(limiter)
|
||||
}
|
||||
|
||||
// ===== Redis 分布式限流中间件 =====
|
||||
|
||||
// RedisRateLimit Redis 分布式限流中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多实例部署时共享限流状态
|
||||
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
|
||||
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
|
||||
// 可选:使用用户ID作为标识(登录后)
|
||||
// userID := GetUserID(c)
|
||||
// if userID > 0 {
|
||||
// identifier = fmt.Sprintf("user:%d", userID)
|
||||
// }
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
// Redis 错误时允许请求,避免影响业务
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流
|
||||
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := identifierFunc(c)
|
||||
if identifier == "" {
|
||||
identifier = c.ClientIP()
|
||||
}
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LoginRedisRateLimit 登录接口 Redis 分布式限流
|
||||
func LoginRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("login_limit", 10)
|
||||
}
|
||||
|
||||
// APIRedisRateLimit API Redis 分布式限流
|
||||
func APIRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("api_limit", 100)
|
||||
}
|
||||
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流
|
||||
func UploadRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("upload_limit", 20)
|
||||
}
|
||||
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
response.RateLimit(c)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Recover panic恢复中间件,捕获panic并返回统一错误响应
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 生产环境必备,防止panic导致服务崩溃
|
||||
func Recover() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// 获取请求ID
|
||||
requestID := GetRequestID(c)
|
||||
|
||||
// 记录错误日志
|
||||
logger.Error("Panic recovered",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("error", fmt.Sprintf("%v", err)),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
// 返回错误响应
|
||||
response.FailWithCode(c, response.CodeServerError, "服务器内部错误")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RecoverWithDetail panic恢复中间件(详细版本,返回更多信息)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 开发环境使用,返回详细错误信息便于调试
|
||||
// 注意: 生产环境不应使用,会暴露敏感信息
|
||||
func RecoverWithDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
requestID := GetRequestID(c)
|
||||
|
||||
logger.Error("Panic recovered",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("error", fmt.Sprintf("%v", err)),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
// 开发环境返回详细错误
|
||||
response.FailWithCode(c, response.CodeServerError, fmt.Sprintf("Panic: %v", err))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequestID 请求ID中间件,为每个请求生成唯一ID便于追踪
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 日志追踪、问题排查必备,简洁高效
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = utils.UUID()
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
c.Header("X-Request-ID", requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetRequestID 从上下文获取请求ID
|
||||
func GetRequestID(c *gin.Context) string {
|
||||
return c.GetString("request_id")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BaseModelWithTime 带时间戳的模型
|
||||
type BaseModelWithTime struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `gorm:"type:datetime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseRepository 基础仓库接口
|
||||
type BaseRepository[T any] interface {
|
||||
// FindByID 根据 ID 查询
|
||||
FindByID(ctx context.Context, id uint) (*T, error)
|
||||
// Create 创建记录
|
||||
Create(ctx context.Context, model *T) error
|
||||
// Update 更新记录
|
||||
Update(ctx context.Context, model *T) error
|
||||
// Delete 删除记录(软删除)
|
||||
Delete(ctx context.Context, id uint) error
|
||||
// FindByIDs 批量查询
|
||||
FindByIDs(ctx context.Context, ids []uint) ([]T, error)
|
||||
}
|
||||
|
||||
// BaseRepo 基础仓库实现
|
||||
type BaseRepo[T any] struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBaseRepo 创建基础仓库
|
||||
func NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T] {
|
||||
return &BaseRepo[T]{db: db}
|
||||
}
|
||||
|
||||
// FindByID 根据 ID 查询
|
||||
func (r *BaseRepo[T]) FindByID(ctx context.Context, id uint) (*T, error) {
|
||||
var model T
|
||||
err := r.db.WithContext(ctx).First(&model, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Create 创建记录
|
||||
func (r *BaseRepo[T]) Create(ctx context.Context, model *T) error {
|
||||
return r.db.WithContext(ctx).Create(model).Error
|
||||
}
|
||||
|
||||
// Update 更新记录
|
||||
func (r *BaseRepo[T]) Update(ctx context.Context, model *T) error {
|
||||
return r.db.WithContext(ctx).Save(model).Error
|
||||
}
|
||||
|
||||
// Delete 删除记录(软删除)
|
||||
func (r *BaseRepo[T]) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(new(T), id).Error
|
||||
}
|
||||
|
||||
// HardDelete 硬删除记录(物理删除)
|
||||
func (r *BaseRepo[T]) HardDelete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Unscoped().Delete(new(T), id).Error
|
||||
}
|
||||
|
||||
// FindByIDs 批量查询
|
||||
func (r *BaseRepo[T]) FindByIDs(ctx context.Context, ids []uint) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindAll 查询所有记录
|
||||
func (r *BaseRepo[T]) FindAll(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// Count 统计数量
|
||||
func (r *BaseRepo[T]) Count(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountWhere 条件统计
|
||||
func (r *BaseRepo[T]) CountWhere(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例
|
||||
func (r *BaseRepo[T]) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// ===== 扩展查询功能 =====
|
||||
|
||||
// FindOne 条件查询单条记录
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 灵活的条件查询,避免每次写原生 SQL
|
||||
func (r *BaseRepo[T]) FindOne(ctx context.Context, query string, args ...any) (*T, error) {
|
||||
var model T
|
||||
err := r.db.WithContext(ctx).Where(query, args...).First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// FindWhere 条件查询多条记录
|
||||
func (r *BaseRepo[T]) FindWhere(ctx context.Context, query string, args ...any) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Where(query, args...).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindWhereOrdered 条件查询并排序
|
||||
func (r *BaseRepo[T]) FindWhereOrdered(ctx context.Context, query string, args []any, order string) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Where(query, args...).Order(order).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindOrdered 查询并排序
|
||||
func (r *BaseRepo[T]) FindOrdered(ctx context.Context, order string, limit int) ([]T, error) {
|
||||
var models []T
|
||||
query := r.db.WithContext(ctx).Order(order)
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindLimited 查询指定数量记录
|
||||
func (r *BaseRepo[T]) FindLimited(ctx context.Context, limit int) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Limit(limit).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// ===== 分页查询 =====
|
||||
|
||||
// PageResult 分页结果
|
||||
type PageResult[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// FindPage 分页查询
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 最常用的分页查询封装
|
||||
func (r *BaseRepo[T]) FindPage(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
|
||||
// 统计总数
|
||||
if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算偏移量
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// 查询数据
|
||||
if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageResult[T]{
|
||||
Items: models,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindPageOrdered 分页查询并排序
|
||||
func (r *BaseRepo[T]) FindPageOrdered(ctx context.Context, page, pageSize int, order string) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(new(T)).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageResult[T]{
|
||||
Items: models,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindPageWhere 条件分页查询
|
||||
func (r *BaseRepo[T]) FindPageWhere(ctx context.Context, page, pageSize int, query string, args ...any) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Where(query, args...).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageResult[T]{
|
||||
Items: models,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindPageWhereOrdered 条件分页查询并排序
|
||||
func (r *BaseRepo[T]) FindPageWhereOrdered(ctx context.Context, page, pageSize int, query string, args []any, order string) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
|
||||
if err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).Where(query, args...).Order(order).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageResult[T]{
|
||||
Items: models,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ===== 批量操作 =====
|
||||
|
||||
// CreateBatch 批量创建
|
||||
func (r *BaseRepo[T]) CreateBatch(ctx context.Context, models []T) error {
|
||||
return r.db.WithContext(ctx).Create(models).Error
|
||||
}
|
||||
|
||||
// UpdateBatch 批量更新(指定字段)
|
||||
func (r *BaseRepo[T]) UpdateBatch(ctx context.Context, ids []uint, field string, value any) error {
|
||||
return r.db.WithContext(ctx).Model(new(T)).Where("id IN ?", ids).Update(field, value).Error
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除
|
||||
func (r *BaseRepo[T]) DeleteBatch(ctx context.Context, ids []uint) error {
|
||||
return r.db.WithContext(ctx).Delete(new(T), ids).Error
|
||||
}
|
||||
|
||||
// HardDeleteBatch 批量硬删除
|
||||
func (r *BaseRepo[T]) HardDeleteBatch(ctx context.Context, ids []uint) error {
|
||||
return r.db.WithContext(ctx).Unscoped().Delete(new(T), ids).Error
|
||||
}
|
||||
|
||||
// ===== 存在性检查 =====
|
||||
|
||||
// Exists 检查是否存在
|
||||
func (r *BaseRepo[T]) Exists(ctx context.Context, id uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ExistsWhere 条件检查是否存在
|
||||
func (r *BaseRepo[T]) ExistsWhere(ctx context.Context, query string, args ...any) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(new(T)).Where(query, args...).Limit(1).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ===== 软删除操作 =====
|
||||
|
||||
// Restore 恢复软删除记录
|
||||
func (r *BaseRepo[T]) Restore(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id = ?", id).Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// RestoreBatch 批量恢复软删除记录
|
||||
func (r *BaseRepo[T]) RestoreBatch(ctx context.Context, ids []uint) error {
|
||||
return r.db.WithContext(ctx).Model(new(T)).Unscoped().Where("id IN ?", ids).Update("deleted_at", nil).Error
|
||||
}
|
||||
|
||||
// FindDeleted 查询已软删除的记录
|
||||
func (r *BaseRepo[T]) FindDeleted(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Unscoped().Where("deleted_at IS NOT NULL").Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// FindAllWithDeleted 查询所有记录(包括软删除)
|
||||
func (r *BaseRepo[T]) FindAllWithDeleted(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := r.db.WithContext(ctx).Unscoped().Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// ===== 事务支持 =====
|
||||
|
||||
// WithTransaction 在事务中执行操作
|
||||
func (r *BaseRepo[T]) WithTransaction(ctx context.Context, fn func(txRepo *BaseRepo[T]) error) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := NewBaseRepo[T](tx)
|
||||
return fn(txRepo)
|
||||
})
|
||||
}
|
||||
|
||||
// ===== QueryBuilder 链式查询 =====
|
||||
|
||||
// QueryBuilder 链式查询构建器
|
||||
type QueryBuilder[T any] struct {
|
||||
db *gorm.DB
|
||||
limit int
|
||||
}
|
||||
|
||||
// NewQueryBuilder 创建查询构建器
|
||||
func (r *BaseRepo[T]) NewQueryBuilder() *QueryBuilder[T] {
|
||||
return &QueryBuilder[T]{db: r.db.Model(new(T))}
|
||||
}
|
||||
|
||||
// Where 添加条件
|
||||
func (qb *QueryBuilder[T]) Where(query string, args ...any) *QueryBuilder[T] {
|
||||
qb.db = qb.db.Where(query, args...)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Or 添加 OR 条件
|
||||
func (qb *QueryBuilder[T]) Or(query string, args ...any) *QueryBuilder[T] {
|
||||
qb.db = qb.db.Or(query, args...)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Order 设置排序
|
||||
func (qb *QueryBuilder[T]) Order(order string) *QueryBuilder[T] {
|
||||
qb.db = qb.db.Order(order)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Limit 设置数量限制
|
||||
func (qb *QueryBuilder[T]) Limit(limit int) *QueryBuilder[T] {
|
||||
qb.limit = limit
|
||||
qb.db = qb.db.Limit(limit)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Offset 设置偏移量
|
||||
func (qb *QueryBuilder[T]) Offset(offset int) *QueryBuilder[T] {
|
||||
qb.db = qb.db.Offset(offset)
|
||||
return qb
|
||||
}
|
||||
|
||||
// Find 执行查询
|
||||
func (qb *QueryBuilder[T]) Find(ctx context.Context) ([]T, error) {
|
||||
var models []T
|
||||
err := qb.db.WithContext(ctx).Find(&models).Error
|
||||
return models, err
|
||||
}
|
||||
|
||||
// First 执行查询并返回第一条
|
||||
func (qb *QueryBuilder[T]) First(ctx context.Context) (*T, error) {
|
||||
var model T
|
||||
err := qb.db.WithContext(ctx).First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Count 执行统计
|
||||
func (qb *QueryBuilder[T]) Count(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := qb.db.WithContext(ctx).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// Page 执行分页查询
|
||||
func (qb *QueryBuilder[T]) Page(ctx context.Context, page, pageSize int) (*PageResult[T], error) {
|
||||
var models []T
|
||||
var total int64
|
||||
|
||||
// 复制 query 用于统计(避免影响原查询)
|
||||
countDB := qb.db.Session(&gorm.Session{})
|
||||
if err := countDB.WithContext(ctx).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := qb.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&models).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PageResult[T]{
|
||||
Items: models,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNewBaseRepo(t *testing.T) {
|
||||
// BaseRepo 需要数据库连接,这里测试结构
|
||||
// 无法直接实例化,验证泛型定义
|
||||
}
|
||||
|
||||
func TestBaseRepoInterface(t *testing.T) {
|
||||
// BaseRepository 接口验证
|
||||
// FindByID, Create, Update, Delete, FindByIDs 方法
|
||||
}
|
||||
|
||||
func TestBaseRepoMethods(t *testing.T) {
|
||||
// 测试方法签名,实际使用需要 DB 连接
|
||||
// FindByID(ctx context.Context, id uint) (*T, error)
|
||||
// Create(ctx context.Context, model *T) error
|
||||
// Update(ctx context.Context, model *T) error
|
||||
// Delete(ctx context.Context, id uint) error
|
||||
// FindByIDs(ctx context.Context, ids []uint) ([]T, error)
|
||||
// FindAll(ctx context.Context) ([]T, error)
|
||||
// Count(ctx context.Context) (int64, error)
|
||||
// GetDB() *gorm.DB
|
||||
}
|
||||
|
||||
func TestRepositoryFunctionSignatures(t *testing.T) {
|
||||
// 验证函数签名存在
|
||||
// NewBaseRepo[T any](db *gorm.DB) *BaseRepo[T]
|
||||
}
|
||||
|
||||
func TestGormDependency(t *testing.T) {
|
||||
// repository 依赖 gorm
|
||||
// gorm.DB 用于数据库操作
|
||||
}
|
||||
|
||||
func TestContextUsage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if ctx == nil {
|
||||
t.Error("context.Background should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormDBType(t *testing.T) {
|
||||
// 验证 gorm.DB 类型
|
||||
var db *gorm.DB
|
||||
if db != nil {
|
||||
// db 未初始化应为 nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRepoStruct(t *testing.T) {
|
||||
// BaseRepo[T any] struct { db *gorm.DB }
|
||||
// 泛型结构体,无法直接测试实例化
|
||||
}
|
||||
|
||||
func TestBaseRepositoryInterface(t *testing.T) {
|
||||
// interface 定义验证
|
||||
// type BaseRepository[T any] interface { ... }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 业务错误码定义
|
||||
// 格式:模块(2位) + 功能(2位) + 错误类型(2位)
|
||||
const (
|
||||
// 通用错误 00xxxx
|
||||
CodeSuccess = 1 // 成功
|
||||
CodeFail = 0 // 通用失败
|
||||
CodeInvalidParams = 1 // 参数错误
|
||||
CodeUnauthorized = 401 // 未授权
|
||||
CodeForbidden = 403 // 无权限
|
||||
CodeNotFound = 404 // 资源不存在
|
||||
CodeRateLimit = 429 // 请求过于频繁
|
||||
CodeServerError = 500 // 服务器错误
|
||||
CodeServiceUnavailable = 503 // 服务不可用
|
||||
|
||||
// 用户模块错误 01xxxx
|
||||
CodeUserNotFound = 10001 // 用户不存在
|
||||
CodeUserAlreadyExists = 10002 // 用户已存在
|
||||
CodeUserDisabled = 10003 // 用户已禁用
|
||||
CodePasswordWrong = 10004 // 密码错误
|
||||
CodePasswordWeak = 10005 // 密码强度不足
|
||||
CodePhoneInvalid = 10006 // 手机号无效
|
||||
CodeEmailInvalid = 10007 // 邮箱无效
|
||||
CodeLoginFailed = 10008 // 登录失败
|
||||
CodeTokenExpired = 10009 // Token 已过期
|
||||
CodeTokenInvalid = 10010 // Token 无效
|
||||
|
||||
// 文件模块错误 02xxxx
|
||||
CodeFileNotFound = 20001 // 文件不存在
|
||||
CodeFileTooLarge = 20002 // 文件过大
|
||||
CodeFileTypeInvalid = 20003 // 文件类型不支持
|
||||
CodeFileUploadFailed = 20004 // 文件上传失败
|
||||
|
||||
// 数据模块错误 03xxxx
|
||||
CodeDataNotFound = 30001 // 数据不存在
|
||||
CodeDataAlreadyExists = 30002 // 数据已存在
|
||||
CodeDataInvalid = 30003 // 数据无效
|
||||
CodeDataConflict = 30004 // 数据冲突
|
||||
|
||||
// 业务模块错误 04xxxx
|
||||
CodeOperationFailed = 40001 // 操作失败
|
||||
CodeOperationTimeout = 40002 // 操作超时
|
||||
CodeBusinessRuleError = 40003 // 业务规则错误
|
||||
)
|
||||
|
||||
// Error 业务错误
|
||||
type Error struct {
|
||||
Code int // 错误码
|
||||
Message string // 错误消息
|
||||
Detail string // 详细信息(可选)
|
||||
}
|
||||
|
||||
// NewError 创建业务错误
|
||||
func NewError(code int, message string) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorWithDetail 创建带详细信息的业务错误
|
||||
func NewErrorWithDetail(code int, message, detail string) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// Error 实现 error 接口
|
||||
func (e *Error) Error() string {
|
||||
if e.Detail != "" {
|
||||
return fmt.Sprintf("[%d] %s: %s", e.Code, e.Message, e.Detail)
|
||||
}
|
||||
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// WithDetail 添加详细信息
|
||||
func (e *Error) WithDetail(detail string) *Error {
|
||||
e.Detail = detail
|
||||
return e
|
||||
}
|
||||
|
||||
// ToResponse 转换为响应结构
|
||||
func (e *Error) ToResponse() Response {
|
||||
return Response{
|
||||
Code: e.Code,
|
||||
Msg: e.Message,
|
||||
}
|
||||
}
|
||||
|
||||
// 预定义错误
|
||||
var (
|
||||
ErrInvalidParams = NewError(CodeInvalidParams, "参数错误")
|
||||
ErrUnauthorized = NewError(CodeUnauthorized, "请先登录")
|
||||
ErrForbidden = NewError(CodeForbidden, "无权限访问")
|
||||
ErrNotFound = NewError(CodeNotFound, "资源不存在")
|
||||
ErrRateLimit = NewError(CodeRateLimit, "请求过于频繁")
|
||||
ErrServerError = NewError(CodeServerError, "服务器错误")
|
||||
ErrServiceUnavailable = NewError(CodeServiceUnavailable, "服务暂时不可用")
|
||||
|
||||
// 用户相关
|
||||
ErrUserNotFound = NewError(CodeUserNotFound, "用户不存在")
|
||||
ErrUserAlreadyExists = NewError(CodeUserAlreadyExists, "用户已存在")
|
||||
ErrUserDisabled = NewError(CodeUserDisabled, "用户已禁用")
|
||||
ErrPasswordWrong = NewError(CodePasswordWrong, "密码错误")
|
||||
ErrPasswordWeak = NewError(CodePasswordWeak, "密码强度不足")
|
||||
ErrPhoneInvalid = NewError(CodePhoneInvalid, "手机号无效")
|
||||
ErrEmailInvalid = NewError(CodeEmailInvalid, "邮箱无效")
|
||||
ErrLoginFailed = NewError(CodeLoginFailed, "登录失败")
|
||||
ErrTokenExpired = NewError(CodeTokenExpired, "登录已过期")
|
||||
ErrTokenInvalid = NewError(CodeTokenInvalid, "Token 无效")
|
||||
|
||||
// 文件相关
|
||||
ErrFileNotFound = NewError(CodeFileNotFound, "文件不存在")
|
||||
ErrFileTooLarge = NewError(CodeFileTooLarge, "文件过大")
|
||||
ErrFileTypeInvalid = NewError(CodeFileTypeInvalid, "文件类型不支持")
|
||||
ErrFileUploadFailed = NewError(CodeFileUploadFailed, "文件上传失败")
|
||||
|
||||
// 数据相关
|
||||
ErrDataNotFound = NewError(CodeDataNotFound, "数据不存在")
|
||||
ErrDataAlreadyExists = NewError(CodeDataAlreadyExists, "数据已存在")
|
||||
ErrDataInvalid = NewError(CodeDataInvalid, "数据无效")
|
||||
ErrDataConflict = NewError(CodeDataConflict, "数据冲突")
|
||||
|
||||
// 业务相关
|
||||
ErrOperationFailed = NewError(CodeOperationFailed, "操作失败")
|
||||
ErrOperationTimeout = NewError(CodeOperationTimeout, "操作超时")
|
||||
ErrBusinessRule = NewError(CodeBusinessRuleError, "业务规则错误")
|
||||
)
|
||||
|
||||
// FailWithError 使用预定义错误响应
|
||||
func FailWithError(c *gin.Context, err *Error) {
|
||||
c.JSON(200, Response{
|
||||
Code: err.Code,
|
||||
Msg: err.Message,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// FailWithDetail 使用预定义错误并添加详细信息
|
||||
func FailWithDetail(c *gin.Context, err *Error, detail string) {
|
||||
c.JSON(200, Response{
|
||||
Code: err.Code,
|
||||
Msg: err.Message,
|
||||
Data: gin.H{"detail": detail},
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response 统一响应结构
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"` // 请求追踪ID
|
||||
}
|
||||
|
||||
// PageData 分页数据结构
|
||||
type PageData struct {
|
||||
Items any `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// getRequestID 从上下文获取请求ID
|
||||
func getRequestID(c *gin.Context) string {
|
||||
return c.GetString("request_id")
|
||||
}
|
||||
|
||||
// Success 成功响应
|
||||
func Success(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeSuccess,
|
||||
Msg: "操作成功",
|
||||
Data: data,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// SuccessWithMsg 成功响应(自定义消息)
|
||||
func SuccessWithMsg(c *gin.Context, msg string, data any) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeSuccess,
|
||||
Msg: msg,
|
||||
Data: data,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// Fail 失败响应
|
||||
func Fail(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeFail,
|
||||
Msg: msg,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// FailWithCode 失败响应(自定义错误码)
|
||||
func FailWithCode(c *gin.Context, code int, msg string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: code,
|
||||
Msg: msg,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// Unauthorized 未授权响应
|
||||
func Unauthorized(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeUnauthorized,
|
||||
Msg: msg,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// NotFound 资源不存在响应
|
||||
func NotFound(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeNotFound,
|
||||
Msg: msg,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// ServerError 服务器错误响应
|
||||
func ServerError(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeServerError,
|
||||
Msg: msg,
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimit 请求过于频繁响应
|
||||
func RateLimit(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, Response{
|
||||
Code: CodeRateLimit,
|
||||
Msg: "请求过于频繁,请稍后再试",
|
||||
RequestID: getRequestID(c),
|
||||
})
|
||||
}
|
||||
|
||||
// Page 分页响应
|
||||
func Page(c *gin.Context, items any, total int64, page, pageSize int) {
|
||||
Success(c, PageData{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Download 文件下载响应
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 文件下载封装,自动设置响应头
|
||||
func Download(c *gin.Context, filename string, data []byte) {
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Header("Content-Length", utils.ToString(len(data)))
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// DownloadWithContentType 文件下载(自定义Content-Type)
|
||||
func DownloadWithContentType(c *gin.Context, filename string, contentType string, data []byte) {
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Header("Content-Length", utils.ToString(len(data)))
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
// HTML HTML内容响应
|
||||
func HTML(c *gin.Context, data string) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, data)
|
||||
}
|
||||
|
||||
// Redirect 页面跳转
|
||||
func Redirect(c *gin.Context, code int, url string) {
|
||||
c.Redirect(code, url)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSuccess(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Success status = %d", w.Code)
|
||||
}
|
||||
|
||||
// 验证响应体包含 code=1
|
||||
body := w.Body.String()
|
||||
if !contains(body, `"code":1`) {
|
||||
t.Errorf("Success body should contain code:1, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessWithMsg(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.SuccessWithMsg(c, "操作成功", gin.H{"id": 1})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("SuccessWithMsg status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFail(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.Fail(c, "参数错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Fail status = %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !contains(body, `"code":0`) {
|
||||
t.Errorf("Fail body should contain code:0, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailWithCode(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.FailWithCode(c, response.CodeUnauthorized, "未授权")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("FailWithCode status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorized(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Unauthorized status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.NotFound(c, "资源不存在")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("NotFound status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerError(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.ServerError(c, "服务器错误")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("ServerError status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimit(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.RateLimit(c)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("RateLimit status = %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !contains(body, `"code":`) {
|
||||
t.Errorf("RateLimit body should contain code, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPage(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
items := []string{"a", "b", "c"}
|
||||
response.Page(c, items, 100, 1, 20)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Page status = %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !contains(body, `"total":100`) {
|
||||
t.Errorf("Page body should contain total:100, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
data := []byte("test file content")
|
||||
response.Download(c, "test.txt", data)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Download status = %d", w.Code)
|
||||
}
|
||||
|
||||
// 验证响应头
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/octet-stream" {
|
||||
t.Errorf("Download Content-Type = %s, want application/octet-stream", contentType)
|
||||
}
|
||||
|
||||
disposition := w.Header().Get("Content-Disposition")
|
||||
if !contains(disposition, "test.txt") {
|
||||
t.Errorf("Download Content-Disposition should contain filename, got %s", disposition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWithContentType(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
data := []byte("test file content")
|
||||
response.DownloadWithContentType(c, "test.pdf", "application/pdf", data)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("DownloadWithContentType status = %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/pdf" {
|
||||
t.Errorf("DownloadWithContentType = %s, want application/pdf", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTML(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.HTML(c, "<html><body>Hello</body></html>")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("HTML status = %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !contains(contentType, "text/html") {
|
||||
t.Errorf("HTML Content-Type should be text/html, got %s", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirect(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
response.Redirect(c, 302, "/new-location")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 重定向返回 302
|
||||
if w.Code != 302 {
|
||||
t.Errorf("Redirect status = %d, want 302", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/new-location" {
|
||||
t.Errorf("Redirect Location = %s, want /new-location", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCodes(t *testing.T) {
|
||||
// 测试错误码定义
|
||||
if response.CodeSuccess != 1 {
|
||||
t.Errorf("CodeSuccess = %d, want 1", response.CodeSuccess)
|
||||
}
|
||||
if response.CodeFail != 0 {
|
||||
t.Errorf("CodeFail = %d, want 0", response.CodeFail)
|
||||
}
|
||||
if response.CodeUnauthorized == 0 {
|
||||
t.Error("CodeUnauthorized should not be 0")
|
||||
}
|
||||
if response.CodeNotFound == 0 {
|
||||
t.Error("CodeNotFound should not be 0")
|
||||
}
|
||||
if response.CodeServerError == 0 {
|
||||
t.Error("CodeServerError should not be 0")
|
||||
}
|
||||
if response.CodeRateLimit == 0 {
|
||||
t.Error("CodeRateLimit should not be 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseStructure(t *testing.T) {
|
||||
// 测试 Response 结构体
|
||||
resp := response.Response{
|
||||
Code: 1,
|
||||
Msg: "成功",
|
||||
Data: gin.H{"id": 1},
|
||||
RequestID: "test-123",
|
||||
}
|
||||
|
||||
if resp.Code != 1 {
|
||||
t.Error("Response Code failed")
|
||||
}
|
||||
if resp.Msg != "成功" {
|
||||
t.Error("Response Msg failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageDataStructure(t *testing.T) {
|
||||
// 测试 PageData 结构体
|
||||
data := response.PageData{
|
||||
Items: []string{"a", "b"},
|
||||
Total: 100,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
}
|
||||
|
||||
if data.Total != 100 {
|
||||
t.Error("PageData Total failed")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// RegisterDefaultRoutes 注册框架默认路由(健康检查、Swagger)
|
||||
// 用户可以选择使用或不使用这些默认路由
|
||||
func RegisterDefaultRoutes(r *gin.Engine) {
|
||||
// Swagger 文档路由
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultModule 默认路由模块(可用于 WithModules)
|
||||
var DefaultModule = &defaultModule{}
|
||||
|
||||
type defaultModule struct{}
|
||||
|
||||
func (m *defaultModule) Name() string { return "default" }
|
||||
func (m *defaultModule) Register(r *gin.RouterGroup) {
|
||||
// 作为模块注册时,路由在根路径
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
// Module 路由模块接口
|
||||
// 用户实现此接口来注册业务路由
|
||||
type Module interface {
|
||||
// Name 模块名称(用于日志和调试)
|
||||
Name() string
|
||||
// Register 注册路由到指定组
|
||||
Register(r *gin.RouterGroup)
|
||||
}
|
||||
|
||||
// ModuleFunc 函数式模块(简化单文件模块注册)
|
||||
type ModuleFunc func(r *gin.RouterGroup)
|
||||
|
||||
// Register 实现 Module 接口
|
||||
func (f ModuleFunc) Register(r *gin.RouterGroup) {
|
||||
f(r)
|
||||
}
|
||||
|
||||
// Name 实现 Module 接口(函数式模块默认名称)
|
||||
func (f ModuleFunc) Name() string {
|
||||
return "func-module"
|
||||
}
|
||||
|
||||
// VersionedAPI 版本化 API 配置
|
||||
type VersionedAPI struct {
|
||||
Version string // 版本标识,如 "v1", "v2"
|
||||
BasePath string // 基础路径,如 "/api/v1"
|
||||
Modules []Module // 该版本的模块列表
|
||||
Middlewares []gin.HandlerFunc // 该版本的公共中间件
|
||||
}
|
||||
|
||||
// MiddlewareGroup 中间件分组
|
||||
type MiddlewareGroup struct {
|
||||
Name string
|
||||
Middlewares []gin.HandlerFunc
|
||||
}
|
||||
|
||||
// Registry 路由注册中心
|
||||
type Registry struct {
|
||||
engine *gin.Engine
|
||||
modules []Module
|
||||
versions map[string]*VersionedAPI
|
||||
middlewareGroups map[string]*MiddlewareGroup
|
||||
globalMiddlewares []gin.HandlerFunc
|
||||
}
|
||||
|
||||
// NewRegistry 创建路由注册中心
|
||||
func NewRegistry(engine *gin.Engine) *Registry {
|
||||
return &Registry{
|
||||
engine: engine,
|
||||
modules: make([]Module, 0),
|
||||
versions: make(map[string]*VersionedAPI),
|
||||
middlewareGroups: make(map[string]*MiddlewareGroup),
|
||||
}
|
||||
}
|
||||
|
||||
// Use 注册全局中间件
|
||||
func (r *Registry) Use(middlewares ...gin.HandlerFunc) *Registry {
|
||||
r.globalMiddlewares = append(r.globalMiddlewares, middlewares...)
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterModule 注册模块(无版本)
|
||||
func (r *Registry) RegisterModule(module Module) *Registry {
|
||||
r.modules = append(r.modules, module)
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterModuleFunc 注册函数式模块
|
||||
func (r *Registry) RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry {
|
||||
return r.RegisterModule(&namedModule{name: name, fn: fn})
|
||||
}
|
||||
|
||||
// namedModule 命名模块包装(内部类型)
|
||||
type namedModule struct {
|
||||
name string
|
||||
fn func(r *gin.RouterGroup)
|
||||
}
|
||||
|
||||
func (m *namedModule) Name() string { return m.name }
|
||||
func (m *namedModule) Register(r *gin.RouterGroup) { m.fn(r) }
|
||||
|
||||
// RegisterVersion 注册版本化 API
|
||||
func (r *Registry) RegisterVersion(version *VersionedAPI) *Registry {
|
||||
r.versions[version.Version] = version
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterMiddlewareGroup 注册中间件分组
|
||||
func (r *Registry) RegisterMiddlewareGroup(group *MiddlewareGroup) *Registry {
|
||||
r.middlewareGroups[group.Name] = group
|
||||
return r
|
||||
}
|
||||
|
||||
// GetMiddlewareGroup 获取中间件分组
|
||||
func (r *Registry) GetMiddlewareGroup(name string) []gin.HandlerFunc {
|
||||
if group, ok := r.middlewareGroups[name]; ok {
|
||||
return group.Middlewares
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply 应用所有路由注册
|
||||
func (r *Registry) Apply() {
|
||||
// 应用全局中间件
|
||||
r.engine.Use(r.globalMiddlewares...)
|
||||
|
||||
// 注册无版本模块
|
||||
for _, module := range r.modules {
|
||||
module.Register(r.engine.Group(""))
|
||||
}
|
||||
|
||||
// 注册版本化 API
|
||||
for _, v := range r.versions {
|
||||
group := r.engine.Group(v.BasePath)
|
||||
if len(v.Middlewares) > 0 {
|
||||
group.Use(v.Middlewares...)
|
||||
}
|
||||
for _, module := range v.Modules {
|
||||
module.Register(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 全局注册中心 =====
|
||||
|
||||
var globalRegistry *Registry
|
||||
|
||||
// Init 初始化全局注册中心
|
||||
func Init(engine *gin.Engine) *Registry {
|
||||
globalRegistry = NewRegistry(engine)
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// GetRegistry 获取全局注册中心
|
||||
func GetRegistry() *Registry {
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// Use 注册全局中间件(全局方式)
|
||||
func Use(middlewares ...gin.HandlerFunc) *Registry {
|
||||
return globalRegistry.Use(middlewares...)
|
||||
}
|
||||
|
||||
// RegisterModule 注册模块(全局方式)
|
||||
func RegisterModule(module Module) *Registry {
|
||||
return globalRegistry.RegisterModule(module)
|
||||
}
|
||||
|
||||
// RegisterModuleFunc 注册函数式模块(全局方式)
|
||||
func RegisterModuleFunc(name string, fn func(r *gin.RouterGroup)) *Registry {
|
||||
return globalRegistry.RegisterModuleFunc(name, fn)
|
||||
}
|
||||
|
||||
// RegisterVersion 注册版本化 API(全局方式)
|
||||
func RegisterVersion(version *VersionedAPI) *Registry {
|
||||
return globalRegistry.RegisterVersion(version)
|
||||
}
|
||||
|
||||
// Apply 应用路由注册(全局方式)
|
||||
func Apply() {
|
||||
globalRegistry.Apply()
|
||||
}
|
||||
|
||||
// ===== 快捷构建函数 =====
|
||||
|
||||
// NewVersion 创建版本化 API
|
||||
func NewVersion(version, basePath string, middlewares ...gin.HandlerFunc) *VersionedAPI {
|
||||
return &VersionedAPI{
|
||||
Version: version,
|
||||
BasePath: basePath,
|
||||
Middlewares: middlewares,
|
||||
Modules: make([]Module, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddModule 为版本添加模块
|
||||
func (v *VersionedAPI) AddModule(module Module) *VersionedAPI {
|
||||
v.Modules = append(v.Modules, module)
|
||||
return v
|
||||
}
|
||||
|
||||
// AddModuleFunc 为版本添加函数式模块
|
||||
func (v *VersionedAPI) AddModuleFunc(name string, fn func(r *gin.RouterGroup)) *VersionedAPI {
|
||||
return v.AddModule(&namedModule{name: name, fn: fn})
|
||||
}
|
||||
|
||||
// NewMiddlewareGroup 创建中间件分组
|
||||
func NewMiddlewareGroup(name string, middlewares ...gin.HandlerFunc) *MiddlewareGroup {
|
||||
return &MiddlewareGroup{
|
||||
Name: name,
|
||||
Middlewares: middlewares,
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 路由组辅助 =====
|
||||
|
||||
// Group 创建路由组(带中间件分组)
|
||||
func Group(engine *gin.Engine, path string, middlewares ...gin.HandlerFunc) *gin.RouterGroup {
|
||||
return engine.Group(path, middlewares...)
|
||||
}
|
||||
|
||||
// GroupWithMiddlewareGroup 使用中间件分组创建路由组
|
||||
func GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup {
|
||||
middlewares := GetRegistry().GetMiddlewareGroup(groupName)
|
||||
return engine.Group(path, middlewares...)
|
||||
}
|
||||
|
||||
// RESTfulRoute RESTful 路由快捷注册
|
||||
type RESTfulRoute struct {
|
||||
Group *gin.RouterGroup
|
||||
Path string
|
||||
}
|
||||
|
||||
// NewRESTful 创建 RESTful 路由
|
||||
func NewRESTful(group *gin.RouterGroup, path string) *RESTfulRoute {
|
||||
return &RESTfulRoute{Group: group, Path: path}
|
||||
}
|
||||
|
||||
// GET 注册 GET 路由
|
||||
func (r *RESTfulRoute) GET(handlers ...gin.HandlerFunc) {
|
||||
r.Group.GET(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// POST 注册 POST 路由
|
||||
func (r *RESTfulRoute) POST(handlers ...gin.HandlerFunc) {
|
||||
r.Group.POST(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// PUT 注册 PUT 路由
|
||||
func (r *RESTfulRoute) PUT(handlers ...gin.HandlerFunc) {
|
||||
r.Group.PUT(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// DELETE 注册 DELETE 路由
|
||||
func (r *RESTfulRoute) DELETE(handlers ...gin.HandlerFunc) {
|
||||
r.Group.DELETE(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// PATCH 注册 PATCH 路由
|
||||
func (r *RESTfulRoute) PATCH(handlers ...gin.HandlerFunc) {
|
||||
r.Group.PATCH(r.Path, handlers...)
|
||||
}
|
||||
|
||||
// CRUD 注册标准 CRUD 路由
|
||||
// GET /path - 列表
|
||||
// GET /path/:id - 详情
|
||||
// POST /path - 创建
|
||||
// PUT /path/:id - 更新
|
||||
// DELETE /path/:id - 删除
|
||||
func (r *RESTfulRoute) CRUD(list, detail, create, update, delete gin.HandlerFunc) {
|
||||
if list != nil {
|
||||
r.Group.GET(r.Path, list)
|
||||
}
|
||||
if detail != nil {
|
||||
r.Group.GET(r.Path+"/:id", detail)
|
||||
}
|
||||
if create != nil {
|
||||
r.Group.POST(r.Path, create)
|
||||
}
|
||||
if update != nil {
|
||||
r.Group.PUT(r.Path+"/:id", update)
|
||||
}
|
||||
if delete != nil {
|
||||
r.Group.DELETE(r.Path+"/:id", delete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package router_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/router"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return gin.New()
|
||||
}
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
if registry == nil {
|
||||
t.Error("NewRegistry should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUse(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
result := registry.Use(func(c *gin.Context) { c.Next() })
|
||||
if result == nil {
|
||||
t.Error("Use should return registry for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterModule(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
module := &testModule{name: "test"}
|
||||
result := registry.RegisterModule(module)
|
||||
if result == nil {
|
||||
t.Error("RegisterModule should return registry for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterModuleFunc(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
result := registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) {
|
||||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{}) })
|
||||
})
|
||||
if result == nil {
|
||||
t.Error("RegisterModuleFunc should return registry for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVersion(t *testing.T) {
|
||||
v := router.NewVersion("v1", "/api/v1")
|
||||
|
||||
if v.Version != "v1" {
|
||||
t.Errorf("Version = %s, want v1", v.Version)
|
||||
}
|
||||
if v.BasePath != "/api/v1" {
|
||||
t.Errorf("BasePath = %s, want /api/v1", v.BasePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionAddModule(t *testing.T) {
|
||||
v := router.NewVersion("v1", "/api/v1")
|
||||
module := &testModule{name: "user"}
|
||||
|
||||
result := v.AddModule(module)
|
||||
if result == nil {
|
||||
t.Error("AddModule should return VersionedAPI for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionAddModuleFunc(t *testing.T) {
|
||||
v := router.NewVersion("v1", "/api/v1")
|
||||
|
||||
result := v.AddModuleFunc("user", func(r *gin.RouterGroup) {
|
||||
r.GET("/users", func(c *gin.Context) {})
|
||||
})
|
||||
if result == nil {
|
||||
t.Error("AddModuleFunc should return VersionedAPI for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterVersion(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
v := router.NewVersion("v1", "/api/v1")
|
||||
result := registry.RegisterVersion(v)
|
||||
if result == nil {
|
||||
t.Error("RegisterVersion should return registry for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMiddlewareGroup(t *testing.T) {
|
||||
group := router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() })
|
||||
|
||||
if group.Name != "auth" {
|
||||
t.Errorf("Name = %s, want auth", group.Name)
|
||||
}
|
||||
if len(group.Middlewares) != 1 {
|
||||
t.Errorf("Middlewares length = %d, want 1", len(group.Middlewares))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMiddlewareGroup(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
group := router.NewMiddlewareGroup("auth")
|
||||
result := registry.RegisterMiddlewareGroup(group)
|
||||
if result == nil {
|
||||
t.Error("RegisterMiddlewareGroup should return registry for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMiddlewareGroup(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
middleware := func(c *gin.Context) { c.Next() }
|
||||
group := router.NewMiddlewareGroup("auth", middleware)
|
||||
registry.RegisterMiddlewareGroup(group)
|
||||
|
||||
mws := registry.GetMiddlewareGroup("auth")
|
||||
if len(mws) != 1 {
|
||||
t.Errorf("GetMiddlewareGroup length = %d, want 1", len(mws))
|
||||
}
|
||||
|
||||
// 不存在的分组
|
||||
mws2 := registry.GetMiddlewareGroup("nonexistent")
|
||||
if mws2 != nil {
|
||||
t.Error("GetMiddlewareGroup nonexistent should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) {
|
||||
r.GET("/hello", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "hello"})
|
||||
})
|
||||
})
|
||||
|
||||
registry.Apply()
|
||||
|
||||
// 测试路由是否生效
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/hello", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Apply route status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithVersion(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
v1 := router.NewVersion("v1", "/api/v1")
|
||||
v1.AddModuleFunc("user", func(r *gin.RouterGroup) {
|
||||
r.GET("/users", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"version": "v1"})
|
||||
})
|
||||
})
|
||||
registry.RegisterVersion(v1)
|
||||
|
||||
registry.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/users", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Versioned route status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWithMiddleware(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
// 全局中间件
|
||||
registry.Use(func(c *gin.Context) {
|
||||
c.Set("global", true)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
registry.RegisterModuleFunc("test", func(r *gin.RouterGroup) {
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
val, _ := c.Get("global")
|
||||
c.JSON(200, gin.H{"global": val})
|
||||
})
|
||||
})
|
||||
|
||||
registry.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Middleware route status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionMiddleware(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
v1 := router.NewVersion("v1", "/api/v1", func(c *gin.Context) {
|
||||
c.Set("version", "v1")
|
||||
c.Next()
|
||||
})
|
||||
v1.AddModuleFunc("user", func(r *gin.RouterGroup) {
|
||||
r.GET("/users", func(c *gin.Context) {
|
||||
val, _ := c.Get("version")
|
||||
c.JSON(200, gin.H{"version": val})
|
||||
})
|
||||
})
|
||||
registry.RegisterVersion(v1)
|
||||
|
||||
registry.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/users", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Version middleware route status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleVersions(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
v1 := router.NewVersion("v1", "/api/v1")
|
||||
v1.AddModuleFunc("user", func(r *gin.RouterGroup) {
|
||||
r.GET("/users", func(c *gin.Context) { c.JSON(200, gin.H{"version": "v1"}) })
|
||||
})
|
||||
|
||||
v2 := router.NewVersion("v2", "/api/v2")
|
||||
v2.AddModuleFunc("user", func(r *gin.RouterGroup) {
|
||||
r.GET("/users", func(c *gin.Context) { c.JSON(200, gin.H{"version": "v2"}) })
|
||||
})
|
||||
|
||||
registry.RegisterVersion(v1)
|
||||
registry.RegisterVersion(v2)
|
||||
registry.Apply()
|
||||
|
||||
// 测试 v1
|
||||
w1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest("GET", "/api/v1/users", nil)
|
||||
engine.ServeHTTP(w1, req1)
|
||||
|
||||
// 测试 v2
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/api/v2/users", nil)
|
||||
engine.ServeHTTP(w2, req2)
|
||||
|
||||
if w1.Code != 200 || w2.Code != 200 {
|
||||
t.Error("Multiple versions should both work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitAndGetRegistry(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.Init(engine)
|
||||
|
||||
if registry == nil {
|
||||
t.Error("Init should return registry")
|
||||
}
|
||||
|
||||
registry2 := router.GetRegistry()
|
||||
if registry2 != registry {
|
||||
t.Error("GetRegistry should return same registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalFunctions(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
router.Init(engine)
|
||||
|
||||
router.Use(func(c *gin.Context) { c.Next() })
|
||||
router.RegisterModule(&testModule{name: "test"})
|
||||
router.RegisterVersion(router.NewVersion("v1", "/api/v1"))
|
||||
|
||||
registry := router.GetRegistry()
|
||||
if registry == nil {
|
||||
t.Error("Global functions should work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalApply(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
router.Init(engine)
|
||||
|
||||
router.RegisterModuleFunc("hello", func(r *gin.RouterGroup) {
|
||||
r.GET("/hello", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "hello"})
|
||||
})
|
||||
})
|
||||
|
||||
router.Apply()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/hello", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("Global Apply status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleFunc(t *testing.T) {
|
||||
var mf router.ModuleFunc = func(r *gin.RouterGroup) {
|
||||
r.GET("/test", func(c *gin.Context) {})
|
||||
}
|
||||
|
||||
// 验证实现了 Module 接口
|
||||
var _ router.Module = mf
|
||||
}
|
||||
|
||||
func TestRESTfulRoute(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
group := engine.Group("/api")
|
||||
|
||||
rest := router.NewRESTful(group, "/users")
|
||||
rest.GET(func(c *gin.Context) { c.JSON(200, gin.H{}) })
|
||||
rest.POST(func(c *gin.Context) { c.JSON(201, gin.H{}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/users", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("RESTful GET status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTfulCRUD(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
group := engine.Group("/api")
|
||||
|
||||
rest := router.NewRESTful(group, "/items")
|
||||
rest.CRUD(
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"action": "list"}) },
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"action": "detail"}) },
|
||||
func(c *gin.Context) { c.JSON(201, gin.H{"action": "create"}) },
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{"action": "update"}) },
|
||||
func(c *gin.Context) { c.JSON(204, gin.H{}) },
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
code int
|
||||
}{
|
||||
{"GET", "/api/items", 200},
|
||||
{"GET", "/api/items/1", 200},
|
||||
{"POST", "/api/items", 201},
|
||||
{"PUT", "/api/items/1", 200},
|
||||
{"DELETE", "/api/items/1", 204},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.code {
|
||||
t.Errorf("%s %s status = %d, want %d", tt.method, tt.path, w.Code, tt.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRESTfulPartialCRUD(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
group := engine.Group("/api")
|
||||
|
||||
rest := router.NewRESTful(group, "/items")
|
||||
// 只注册 list 和 create
|
||||
rest.CRUD(
|
||||
func(c *gin.Context) { c.JSON(200, gin.H{}) }, // list
|
||||
nil, // no detail
|
||||
func(c *gin.Context) { c.JSON(201, gin.H{}) }, // create
|
||||
nil, // no update
|
||||
nil, // no delete
|
||||
)
|
||||
|
||||
// list 存在
|
||||
w1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest("GET", "/api/items", nil)
|
||||
engine.ServeHTTP(w1, req1)
|
||||
if w1.Code != 200 {
|
||||
t.Error("Partial CRUD list should work")
|
||||
}
|
||||
|
||||
// detail 不存在(404)
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/api/items/1", nil)
|
||||
engine.ServeHTTP(w2, req2)
|
||||
if w2.Code != 404 {
|
||||
t.Errorf("Partial CRUD detail should be 404, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroup(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
group := router.Group(engine, "/api", func(c *gin.Context) { c.Next() })
|
||||
|
||||
if group == nil {
|
||||
t.Error("Group should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupWithMiddlewareGroup(t *testing.T) {
|
||||
engine := setupTestRouter()
|
||||
registry := router.NewRegistry(engine)
|
||||
|
||||
registry.RegisterMiddlewareGroup(router.NewMiddlewareGroup("auth", func(c *gin.Context) { c.Next() }))
|
||||
|
||||
group := router.GroupWithMiddlewareGroup(engine, "/api", "auth")
|
||||
if group == nil {
|
||||
t.Error("GroupWithMiddlewareGroup should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleInterface(t *testing.T) {
|
||||
module := &testModule{name: "test"}
|
||||
|
||||
// 测试 Name 方法
|
||||
if module.Name() != "test" {
|
||||
t.Error("Module Name failed")
|
||||
}
|
||||
|
||||
// 测试实现了接口
|
||||
var _ router.Module = module
|
||||
}
|
||||
|
||||
// 测试模块实现
|
||||
type testModule struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (m *testModule) Name() string { return m.name }
|
||||
func (m *testModule) Register(r *gin.RouterGroup) {
|
||||
r.GET("/"+m.name, func(c *gin.Context) { c.JSON(200, gin.H{"module": m.name}) })
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SSEWriter SSE 写入器
|
||||
type SSEWriter struct {
|
||||
writer gin.ResponseWriter
|
||||
flusher http.Flusher
|
||||
}
|
||||
|
||||
// NewSSEWriter 创建 SSE 写入器
|
||||
func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
|
||||
// 设置 SSE 必要的响应头
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Transfer-Encoding", "chunked")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("响应写入器不支持 flushing")
|
||||
}
|
||||
|
||||
return &SSEWriter{
|
||||
writer: c.Writer,
|
||||
flusher: flusher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteEvent 写入 SSE 事件
|
||||
// 格式: event: <event>\ndata: <data>\n\n
|
||||
func (w *SSEWriter) WriteEvent(event, data string) error {
|
||||
fmt.Fprintf(w.writer, "event: %s\n", event)
|
||||
fmt.Fprintf(w.writer, "data: %s\n\n", data)
|
||||
w.flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteMessage 写入消息(无事件类型)
|
||||
// 格式: data: <data>\n\n
|
||||
func (w *SSEWriter) WriteMessage(data string) error {
|
||||
fmt.Fprintf(w.writer, "data: %s\n\n", data)
|
||||
w.flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteJSON 写入 JSON 数据
|
||||
func (w *SSEWriter) WriteJSON(event string, data any) error {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.WriteEvent(event, string(jsonData))
|
||||
}
|
||||
|
||||
// WriteError 写入错误事件
|
||||
func (w *SSEWriter) WriteError(err error) error {
|
||||
return w.WriteJSON("error", gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
// WriteDone 写入完成事件
|
||||
func (w *SSEWriter) WriteDone() error {
|
||||
return w.WriteEvent("done", "")
|
||||
}
|
||||
|
||||
// KeepAlive 发送保持连接的心跳
|
||||
func (w *SSEWriter) KeepAlive() error {
|
||||
return w.WriteMessage("")
|
||||
}
|
||||
|
||||
// Stream 流式发送数据
|
||||
func (w *SSEWriter) Stream(event string, ch <-chan any) error {
|
||||
for data := range ch {
|
||||
if err := w.WriteJSON(event, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.WriteDone()
|
||||
}
|
||||
|
||||
// SSE 中间件,设置必要的响应头
|
||||
func SSE() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StreamText 流式发送文本(适用于 AI 对话场景)
|
||||
func StreamText(c *gin.Context, ch <-chan string) error {
|
||||
writer, err := NewSSEWriter(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for text := range ch {
|
||||
if err := writer.WriteJSON("message", gin.H{"text": text}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writer.WriteDone()
|
||||
}
|
||||
|
||||
// StreamChunks 流式发送文本块(带增量标记)
|
||||
func StreamChunks(c *gin.Context, ch <-chan string) error {
|
||||
writer, err := NewSSEWriter(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for chunk := range ch {
|
||||
if err := writer.WriteJSON("chunk", gin.H{"delta": chunk}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writer.WriteJSON("done", gin.H{"finished": true})
|
||||
}
|
||||
|
||||
// StreamWithID 流式发送带消息 ID 的数据
|
||||
func StreamWithID(c *gin.Context, messageID string, ch <-chan string) error {
|
||||
writer, err := NewSSEWriter(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送开始事件
|
||||
if err := writer.WriteJSON("start", gin.H{"id": messageID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送内容块
|
||||
for chunk := range ch {
|
||||
if err := writer.WriteJSON("chunk", gin.H{"id": messageID, "delta": chunk}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 发送完成事件
|
||||
return writer.WriteJSON("done", gin.H{"id": messageID, "finished": true})
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package sse_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/sse"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
return r
|
||||
}
|
||||
|
||||
func TestNewSSEWriter(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, err := sse.NewSSEWriter(c)
|
||||
if err != nil {
|
||||
t.Errorf("NewSSEWriter error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if writer == nil {
|
||||
t.Error("NewSSEWriter should not return nil")
|
||||
}
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func TestSSEWriterWriteEvent(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, _ := sse.NewSSEWriter(c)
|
||||
writer.WriteEvent("message", "test data")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "event: message") {
|
||||
t.Errorf("WriteEvent body should contain 'event: message', got %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "data: test data") {
|
||||
t.Errorf("WriteEvent body should contain 'data: test data', got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEWriterWriteMessage(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, _ := sse.NewSSEWriter(c)
|
||||
writer.WriteMessage("hello world")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "data: hello world") {
|
||||
t.Errorf("WriteMessage body should contain data, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEWriterWriteJSON(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, _ := sse.NewSSEWriter(c)
|
||||
writer.WriteJSON("message", gin.H{"text": "hello"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "event: message") {
|
||||
t.Error("WriteJSON should set event type")
|
||||
}
|
||||
if !strings.Contains(body, `"text":"hello"`) {
|
||||
t.Error("WriteJSON should contain JSON data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEMiddleware(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.Use(sse.SSE())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证 SSE 响应头
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "text/event-stream" {
|
||||
t.Errorf("SSE Content-Type = %s, want text/event-stream", contentType)
|
||||
}
|
||||
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
if cacheControl != "no-cache" {
|
||||
t.Errorf("SSE Cache-Control = %s, want no-cache", cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHeaders(t *testing.T) {
|
||||
r := setupTestRouter()
|
||||
r.GET("/sse", func(c *gin.Context) {
|
||||
writer, err := sse.NewSSEWriter(c)
|
||||
if err != nil {
|
||||
t.Errorf("NewSSEWriter error: %v", err)
|
||||
return
|
||||
}
|
||||
writer.WriteMessage("test")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/sse", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 验证必要响应头
|
||||
if w.Header().Get("Content-Type") != "text/event-stream" {
|
||||
t.Error("Content-Type should be text/event-stream")
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-cache" {
|
||||
t.Error("Cache-Control should be no-cache")
|
||||
}
|
||||
if w.Header().Get("Connection") != "keep-alive" {
|
||||
t.Error("Connection should be keep-alive")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Storage 存储接口
|
||||
type Storage interface {
|
||||
Upload(file *multipart.FileHeader, subdir string) (string, error)
|
||||
UploadFromBytes(data []byte, filename, subdir string) (string, error)
|
||||
GetURL(path string) string
|
||||
Delete(path string) error
|
||||
Get(path string) ([]byte, error)
|
||||
Exists(path string) bool
|
||||
}
|
||||
|
||||
// LocalStorage 本地存储
|
||||
type LocalStorage struct {
|
||||
path string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewLocalStorage 创建本地存储实例
|
||||
func NewLocalStorage(cfg *config.LocalStorageConfig) *LocalStorage {
|
||||
return &LocalStorage{
|
||||
path: cfg.Path,
|
||||
baseURL: cfg.BaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
// 生成存储路径: /年/月/日/文件名
|
||||
now := time.Now()
|
||||
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
|
||||
relativePath := filepath.Join(subdir, datePath)
|
||||
|
||||
// 确保目录存在
|
||||
fullPath := filepath.Join(s.path, relativePath)
|
||||
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
||||
logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
ext := filepath.Ext(file.Filename)
|
||||
filename := fmt.Sprintf("%d%s", now.UnixNano(), ext)
|
||||
dst := filepath.Join(fullPath, filename)
|
||||
|
||||
// 打开源文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// 复制文件内容
|
||||
if _, err := io.Copy(dstFile, src); err != nil {
|
||||
return "", fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回相对路径
|
||||
relativeFilePath := filepath.Join(relativePath, filename)
|
||||
// 统一使用正斜杠
|
||||
relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/")
|
||||
|
||||
logger.Info("文件上传成功", zap.String("path", relativeFilePath))
|
||||
return relativeFilePath, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数组上传文件
|
||||
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
// 生成存储路径: /年/月/日/文件名
|
||||
now := time.Now()
|
||||
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
|
||||
relativePath := filepath.Join(subdir, datePath)
|
||||
|
||||
// 确保目录存在
|
||||
fullPath := filepath.Join(s.path, relativePath)
|
||||
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
||||
logger.Error("创建目录失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成唯一文件名(如果未提供扩展名,添加时间戳)
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
uniqueFilename := fmt.Sprintf("%d%s", now.UnixNano(), ext)
|
||||
dst := filepath.Join(fullPath, uniqueFilename)
|
||||
|
||||
// 创建目标文件
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// 写入文件内容
|
||||
if _, err := io.Copy(dstFile, bytes.NewReader(data)); err != nil {
|
||||
return "", fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回相对路径
|
||||
relativeFilePath := filepath.Join(relativePath, uniqueFilename)
|
||||
// 统一使用正斜杠
|
||||
relativeFilePath = strings.ReplaceAll(relativeFilePath, "\\", "/")
|
||||
|
||||
logger.Info("文件上传成功", zap.String("path", relativeFilePath))
|
||||
return relativeFilePath, nil
|
||||
}
|
||||
|
||||
// GetURL 获取文件访问 URL
|
||||
func (s *LocalStorage) GetURL(path string) string {
|
||||
return fmt.Sprintf("%s/%s", s.baseURL, path)
|
||||
}
|
||||
|
||||
// Delete 删除文件
|
||||
func (s *LocalStorage) Delete(path string) error {
|
||||
fullPath := filepath.Join(s.path, path)
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
logger.Error("删除文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
logger.Info("文件删除成功", zap.String("path", path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取文件内容
|
||||
func (s *LocalStorage) Get(path string) ([]byte, error) {
|
||||
fullPath := filepath.Join(s.path, path)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
logger.Error("读取文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (s *LocalStorage) Exists(path string) bool {
|
||||
fullPath := filepath.Join(s.path, path)
|
||||
_, err := os.Stat(fullPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// OSSStorage OSS 存储
|
||||
type OSSStorage struct {
|
||||
client *oss.Client
|
||||
bucket *oss.Bucket
|
||||
endpoint string
|
||||
bucketName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewOSSStorage 创建 OSS 存储实例
|
||||
func NewOSSStorage(cfg *config.OSSStorageConfig) (*OSSStorage, error) {
|
||||
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 OSS Bucket 失败: %w", err)
|
||||
}
|
||||
|
||||
return &OSSStorage{
|
||||
client: client,
|
||||
bucket: bucket,
|
||||
endpoint: cfg.Endpoint,
|
||||
bucketName: cfg.Bucket,
|
||||
baseURL: cfg.BaseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload 上传文件到 OSS
|
||||
func (s *OSSStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
// 生成存储路径: /年/月/日/文件名
|
||||
now := time.Now()
|
||||
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
|
||||
ext := filepath.Ext(file.Filename)
|
||||
objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(subdir, datePath), now.UnixNano(), ext)
|
||||
|
||||
// 打开源文件
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 上传到 OSS
|
||||
if err := s.bucket.PutObject(objectKey, src); err != nil {
|
||||
logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey))
|
||||
return "", fmt.Errorf("OSS 上传失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("OSS 文件上传成功", zap.String("key", objectKey))
|
||||
return objectKey, nil
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数组上传文件到 OSS
|
||||
func (s *OSSStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
now := time.Now()
|
||||
datePath := fmt.Sprintf("%d/%02d/%02d", now.Year(), now.Month(), now.Day())
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
objectKey := fmt.Sprintf("%s/%d%s", filepath.Join(subdir, datePath), now.UnixNano(), ext)
|
||||
|
||||
// 上传到 OSS
|
||||
if err := s.bucket.PutObject(objectKey, bytes.NewReader(data)); err != nil {
|
||||
logger.Error("OSS 上传失败", zap.Error(err), zap.String("key", objectKey))
|
||||
return "", fmt.Errorf("OSS 上传失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("OSS 文件上传成功", zap.String("key", objectKey))
|
||||
return objectKey, nil
|
||||
}
|
||||
|
||||
// GetURL 获取文件访问 URL
|
||||
func (s *OSSStorage) GetURL(path string) string {
|
||||
if s.baseURL != "" {
|
||||
return fmt.Sprintf("%s/%s", s.baseURL, path)
|
||||
}
|
||||
return fmt.Sprintf("https://%s.%s/%s", s.bucketName, s.endpoint, path)
|
||||
}
|
||||
|
||||
// GetSignedURL 获取带签名的临时访问 URL(用于私有文件)
|
||||
func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, error) {
|
||||
return s.bucket.SignURL(path, oss.HTTPGet, int64(expire.Seconds()))
|
||||
}
|
||||
|
||||
// Delete 删除 OSS 文件
|
||||
func (s *OSSStorage) Delete(path string) error {
|
||||
if err := s.bucket.DeleteObject(path); err != nil {
|
||||
logger.Error("OSS 删除失败", zap.Error(err), zap.String("key", path))
|
||||
return fmt.Errorf("OSS 删除失败: %w", err)
|
||||
}
|
||||
logger.Info("OSS 文件删除成功", zap.String("key", path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取 OSS 文件内容
|
||||
func (s *OSSStorage) Get(path string) ([]byte, error) {
|
||||
body, err := s.bucket.GetObject(path)
|
||||
if err != nil {
|
||||
logger.Error("OSS 读取失败", zap.Error(err), zap.String("key", path))
|
||||
return nil, fmt.Errorf("OSS 读取失败: %w", err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查 OSS 文件是否存在
|
||||
func (s *OSSStorage) Exists(path string) bool {
|
||||
_, err := s.bucket.GetObjectMeta(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// 全局存储实例
|
||||
var storage Storage
|
||||
|
||||
// Init 初始化存储
|
||||
func Init(cfg *config.StorageConfig) error {
|
||||
switch cfg.Driver {
|
||||
case "local":
|
||||
storage = NewLocalStorage(&cfg.Local)
|
||||
logger.Info("使用本地存储", zap.String("path", cfg.Local.Path))
|
||||
case "oss":
|
||||
ossStorage, err := NewOSSStorage(&cfg.OSS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage = ossStorage
|
||||
logger.Info("使用 OSS 存储", zap.String("bucket", cfg.OSS.Bucket))
|
||||
default:
|
||||
return fmt.Errorf("不支持的存储驱动: %s", cfg.Driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func Upload(file *multipart.FileHeader, subdir string) (string, error) {
|
||||
return storage.Upload(file, subdir)
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数组上传文件
|
||||
func UploadFromBytes(data []byte, filename, subdir string) (string, error) {
|
||||
return storage.UploadFromBytes(data, filename, subdir)
|
||||
}
|
||||
|
||||
// GetURL 获取文件访问 URL
|
||||
func GetURL(path string) string {
|
||||
return storage.GetURL(path)
|
||||
}
|
||||
|
||||
// Delete 删除文件
|
||||
func Delete(path string) error {
|
||||
return storage.Delete(path)
|
||||
}
|
||||
|
||||
// Get 获取文件内容
|
||||
func Get(path string) ([]byte, error) {
|
||||
return storage.Get(path)
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func Exists(path string) bool {
|
||||
return storage.Exists(path)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
"github.com/EthanCodeCraft/xlgo-core/storage"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 初始化日志(测试需要)
|
||||
cfg := &config.Config{
|
||||
Log: config.LogConfig{
|
||||
Dir: os.TempDir(),
|
||||
MaxSize: 10,
|
||||
MaxBackups: 1,
|
||||
MaxAge: 1,
|
||||
Compress: false,
|
||||
},
|
||||
}
|
||||
logger.Init(cfg)
|
||||
}
|
||||
|
||||
func TestLocalStorage(t *testing.T) {
|
||||
cfg := &config.LocalStorageConfig{
|
||||
Path: "/tmp/uploads",
|
||||
BaseURL: "http://localhost/uploads",
|
||||
}
|
||||
|
||||
local := storage.NewLocalStorage(cfg)
|
||||
if local == nil {
|
||||
t.Error("NewLocalStorage should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageInterface(t *testing.T) {
|
||||
cfg := &config.LocalStorageConfig{
|
||||
Path: "/tmp/uploads",
|
||||
BaseURL: "http://localhost/uploads",
|
||||
}
|
||||
|
||||
var s storage.Storage = storage.NewLocalStorage(cfg)
|
||||
if s == nil {
|
||||
t.Error("LocalStorage should implement Storage interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageGetURL(t *testing.T) {
|
||||
cfg := &config.LocalStorageConfig{
|
||||
Path: "/uploads",
|
||||
BaseURL: "http://example.com",
|
||||
}
|
||||
|
||||
local := storage.NewLocalStorage(cfg)
|
||||
url := local.GetURL("images/test.jpg")
|
||||
|
||||
expected := "http://example.com/images/test.jpg"
|
||||
if url != expected {
|
||||
t.Errorf("GetURL = %s, want %s", url, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageDeleteGetExists(t *testing.T) {
|
||||
// 创建临时目录
|
||||
tmpDir := filepath.Join(os.TempDir(), "xlgo_storage_test")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.LocalStorageConfig{
|
||||
Path: tmpDir,
|
||||
BaseURL: "http://localhost/uploads",
|
||||
}
|
||||
|
||||
local := storage.NewLocalStorage(cfg)
|
||||
|
||||
// 创建测试文件
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
testData := []byte("hello world")
|
||||
if err := os.WriteFile(testFile, testData, 0644); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// 测试 Exists
|
||||
if !local.Exists("test.txt") {
|
||||
t.Error("Exists should return true for existing file")
|
||||
}
|
||||
|
||||
// 测试 Get
|
||||
data, err := local.Get("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Get failed: %v", err)
|
||||
}
|
||||
if string(data) != "hello world" {
|
||||
t.Errorf("Get data = %s, want 'hello world'", string(data))
|
||||
}
|
||||
|
||||
// 测试 Delete
|
||||
err = local.Delete("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// 验证删除后不存在
|
||||
if local.Exists("test.txt") {
|
||||
t.Error("Exists should return false after delete")
|
||||
}
|
||||
|
||||
// 删除不存在的文件应该失败
|
||||
err = local.Delete("nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Error("Delete should fail for nonexistent file")
|
||||
}
|
||||
|
||||
// Get 不存在的文件应该失败
|
||||
_, err = local.Get("nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Error("Get should fail for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageInitInvalidDriver(t *testing.T) {
|
||||
cfg := &config.StorageConfig{
|
||||
Driver: "invalid",
|
||||
}
|
||||
|
||||
err := storage.Init(cfg)
|
||||
if err == nil {
|
||||
t.Error("Init should fail with invalid driver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSStorageConfig(t *testing.T) {
|
||||
cfg := &config.OSSStorageConfig{
|
||||
Endpoint: "oss-cn-hangzhou.aliyuncs.com",
|
||||
Bucket: "test-bucket",
|
||||
AccessKeyID: "test-id",
|
||||
AccessKeySecret: "test-secret",
|
||||
BaseURL: "https://test.oss-cn-hangzhou.aliyuncs.com",
|
||||
}
|
||||
|
||||
if cfg.Endpoint != "oss-cn-hangzhou.aliyuncs.com" {
|
||||
t.Error("OSSStorageConfig Endpoint failed")
|
||||
}
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupRouter 创建测试路由
|
||||
func SetupRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
return gin.New()
|
||||
}
|
||||
|
||||
// Request 测试请求辅助函数
|
||||
type Request struct {
|
||||
router *gin.Engine
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// NewRequest 创建测试请求
|
||||
func NewRequest(router *gin.Engine, method, path string) *Request {
|
||||
return &Request{
|
||||
router: router,
|
||||
method: method,
|
||||
path: path,
|
||||
headers: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// GET 创建 GET 请求
|
||||
func GET(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodGet, path)
|
||||
}
|
||||
|
||||
// POST 创建 POST 请求
|
||||
func POST(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPost, path)
|
||||
}
|
||||
|
||||
// PUT 创建 PUT 请求
|
||||
func PUT(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPut, path)
|
||||
}
|
||||
|
||||
// DELETE 创建 DELETE 请求
|
||||
func DELETE(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodDelete, path)
|
||||
}
|
||||
|
||||
// PATCH 创建 PATCH 请求
|
||||
func PATCH(router *gin.Engine, path string) *Request {
|
||||
return NewRequest(router, http.MethodPatch, path)
|
||||
}
|
||||
|
||||
// WithBody 设置请求体
|
||||
func (r *Request) WithBody(body any) *Request {
|
||||
r.body = body
|
||||
return r
|
||||
}
|
||||
|
||||
// WithJSON 设置 JSON 请求体
|
||||
func (r *Request) WithJSON(body any) *Request {
|
||||
r.body = body
|
||||
r.headers["Content-Type"] = "application/json"
|
||||
return r
|
||||
}
|
||||
|
||||
// WithHeader 设置请求头
|
||||
func (r *Request) WithHeader(key, value string) *Request {
|
||||
r.headers[key] = value
|
||||
return r
|
||||
}
|
||||
|
||||
// WithToken 设置 Authorization 头
|
||||
func (r *Request) WithToken(token string) *Request {
|
||||
r.headers["Authorization"] = "Bearer " + token
|
||||
return r
|
||||
}
|
||||
|
||||
// Execute 执行请求
|
||||
func (r *Request) Execute() *httptest.ResponseRecorder {
|
||||
var bodyReader *bytes.Reader
|
||||
|
||||
if r.body != nil {
|
||||
bodyBytes, _ := json.Marshal(r.body)
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
} else {
|
||||
bodyReader = bytes.NewReader([]byte{})
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(r.method, r.path, bodyReader)
|
||||
|
||||
for key, value := range r.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
r.router.ServeHTTP(recorder, req)
|
||||
|
||||
return recorder
|
||||
}
|
||||
|
||||
// Response 响应解析辅助
|
||||
type Response struct {
|
||||
*httptest.ResponseRecorder
|
||||
}
|
||||
|
||||
// ParseJSON 解析 JSON 响应
|
||||
func (r *Response) ParseJSON(v any) error {
|
||||
return json.Unmarshal(r.Body.Bytes(), v)
|
||||
}
|
||||
|
||||
// AssertStatus 断言状态码
|
||||
func (r *Response) AssertStatus(t *testing.T, expected int) {
|
||||
t.Helper()
|
||||
if r.Code != expected {
|
||||
t.Errorf("状态码错误: 期望 %d, 实际 %d", expected, r.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertOK 断言状态码为 200
|
||||
func (r *Response) AssertOK(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusOK)
|
||||
}
|
||||
|
||||
// AssertCreated 断言状态码为 201
|
||||
func (r *Response) AssertCreated(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusCreated)
|
||||
}
|
||||
|
||||
// AssertBadRequest 断言状态码为 400
|
||||
func (r *Response) AssertBadRequest(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// AssertUnauthorized 断言状态码为 401
|
||||
func (r *Response) AssertUnauthorized(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// AssertForbidden 断言状态码为 403
|
||||
func (r *Response) AssertForbidden(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// AssertNotFound 断言状态码为 404
|
||||
func (r *Response) AssertNotFound(t *testing.T) {
|
||||
t.Helper()
|
||||
r.AssertStatus(t, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// AssertJSONContains 断言 JSON 包含指定字段
|
||||
func (r *Response) AssertJSONContains(t *testing.T, key string, expected any) {
|
||||
t.Helper()
|
||||
|
||||
var result map[string]any
|
||||
if err := r.ParseJSON(&result); err != nil {
|
||||
t.Errorf("解析 JSON 失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 支持嵌套键(如 "data.user.id")
|
||||
keys := splitKey(key)
|
||||
current := result
|
||||
|
||||
for i, k := range keys {
|
||||
if i == len(keys)-1 {
|
||||
if current[k] != expected {
|
||||
t.Errorf("JSON 字段 %s 错误: 期望 %v, 实际 %v", key, expected, current[k])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
next, ok := current[k].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("JSON 字段 %s 不是对象", k)
|
||||
return
|
||||
}
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
func splitKey(key string) []string {
|
||||
var keys []string
|
||||
start := 0
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == '.' {
|
||||
keys = append(keys, key[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
keys = append(keys, key[start:])
|
||||
return keys
|
||||
}
|
||||
|
||||
// Mock 模拟工具
|
||||
|
||||
// MockDB 模拟数据库(用于简单测试)
|
||||
type MockDB struct {
|
||||
data map[any]any
|
||||
}
|
||||
|
||||
// NewMockDB 创建模拟数据库
|
||||
func NewMockDB() *MockDB {
|
||||
return &MockDB{
|
||||
data: make(map[any]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Set 设置数据
|
||||
func (m *MockDB) Set(key, value any) {
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
// Get 获取数据
|
||||
func (m *MockDB) Get(key any) (any, bool) {
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Delete 删除数据
|
||||
func (m *MockDB) Delete(key any) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Clear 清空数据
|
||||
func (m *MockDB) Clear() {
|
||||
m.data = make(map[any]any)
|
||||
}
|
||||
|
||||
// MockCache 模拟缓存
|
||||
type MockCache struct {
|
||||
data map[string][]byte
|
||||
}
|
||||
|
||||
// NewMockCache 创建模拟缓存
|
||||
func NewMockCache() *MockCache {
|
||||
return &MockCache{
|
||||
data: make(map[string][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
func (m *MockCache) Set(key string, value []byte) {
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
// Get 获取缓存
|
||||
func (m *MockCache) Get(key string) ([]byte, bool) {
|
||||
v, ok := m.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
func (m *MockCache) Delete(key string) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (m *MockCache) Exists(key string) bool {
|
||||
_, ok := m.data[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Clear 清空缓存
|
||||
func (m *MockCache) Clear() {
|
||||
m.data = make(map[string][]byte)
|
||||
}
|
||||
|
||||
// MockStorage 模拟存储
|
||||
type MockStorage struct {
|
||||
files map[string][]byte
|
||||
urls map[string]string
|
||||
}
|
||||
|
||||
// NewMockStorage 创建模拟存储
|
||||
func NewMockStorage() *MockStorage {
|
||||
return &MockStorage{
|
||||
files: make(map[string][]byte),
|
||||
urls: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 模拟上传
|
||||
func (m *MockStorage) Upload(data []byte, filename string) (string, error) {
|
||||
path := "/mock/" + filename
|
||||
m.files[path] = data
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// GetURL 获取 URL
|
||||
func (m *MockStorage) GetURL(path string) string {
|
||||
return "http://mock.test" + path
|
||||
}
|
||||
|
||||
// AssertEqual 通用相等断言
|
||||
func AssertEqual(t *testing.T, expected, actual any) {
|
||||
t.Helper()
|
||||
if expected != actual {
|
||||
t.Errorf("不相等: 期望 %v, 实际 %v", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNotNil 断言不为空
|
||||
func AssertNotNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value == nil {
|
||||
t.Error("期望非空,实际为空")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNil 断言为空
|
||||
func AssertNil(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
if value != nil {
|
||||
t.Errorf("期望为空,实际为: %v", value)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertTrue 断言为真
|
||||
func AssertTrue(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if !value {
|
||||
t.Error("期望为真,实际为假")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFalse 断言为假
|
||||
func AssertFalse(t *testing.T, value bool) {
|
||||
t.Helper()
|
||||
if value {
|
||||
t.Error("期望为假,实际为真")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertError 断言有错误
|
||||
func AssertError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Error("期望有错误,实际无错误")
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoError 断言无错误
|
||||
func AssertNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("期望无错误,实际错误: %v", err)
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Config 链路追踪配置
|
||||
type Config struct {
|
||||
// ServiceName 服务名称
|
||||
ServiceName string
|
||||
// ServiceVersion 服务版本
|
||||
ServiceVersion string
|
||||
// Environment 运行环境
|
||||
Environment string
|
||||
// ExporterType 导出器类型: "otlp-http", "otlp-grpc", "stdout"
|
||||
ExporterType string
|
||||
// Endpoint OTLP 导出器地址
|
||||
Endpoint string
|
||||
// SampleRatio 采样比例 (0.0-1.0)
|
||||
SampleRatio float64
|
||||
// Enabled 是否启用
|
||||
Enabled bool
|
||||
// Propagator 传播器类型: "w3c", "b3", "jaeger"
|
||||
Propagator string
|
||||
}
|
||||
|
||||
// DefaultConfig 默认配置
|
||||
var DefaultConfig = Config{
|
||||
ServiceName: "xlgo-service",
|
||||
ServiceVersion: "1.0.0",
|
||||
Environment: "development",
|
||||
ExporterType: "otlp-http",
|
||||
Endpoint: "localhost:4318",
|
||||
SampleRatio: 1.0,
|
||||
Enabled: false,
|
||||
Propagator: "w3c",
|
||||
}
|
||||
|
||||
// TracerProvider 全局 TracerProvider
|
||||
var tracerProvider *sdktrace.TracerProvider
|
||||
|
||||
// Tracer 全局 Tracer
|
||||
var tracer trace.Tracer
|
||||
|
||||
// Init 初始化链路追踪
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: OpenTelemetry 是行业标准,支持多种导出器
|
||||
func Init(cfg Config) error {
|
||||
if !cfg.Enabled {
|
||||
// 设置 Noop Tracer
|
||||
otel.SetTracerProvider(trace.NewNoopTracerProvider())
|
||||
tracer = otel.Tracer(cfg.ServiceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建资源
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(cfg.ServiceName),
|
||||
semconv.ServiceVersion(cfg.ServiceVersion),
|
||||
attribute.String("environment", cfg.Environment),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建导出器
|
||||
exporter, err := createExporter(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建 TracerProvider
|
||||
tracerProvider = sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(cfg.SampleRatio)),
|
||||
)
|
||||
|
||||
// 设置全局 TracerProvider
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// 设置传播器
|
||||
propagator := createPropagator(cfg.Propagator)
|
||||
otel.SetTextMapPropagator(propagator)
|
||||
|
||||
// 创建 Tracer
|
||||
tracer = otel.Tracer(cfg.ServiceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createExporter 创建导出器
|
||||
func createExporter(cfg Config) (sdktrace.SpanExporter, error) {
|
||||
switch cfg.ExporterType {
|
||||
case "otlp-http":
|
||||
client := otlptracehttp.NewClient(
|
||||
otlptracehttp.WithEndpoint(cfg.Endpoint),
|
||||
)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
case "otlp-grpc":
|
||||
client := otlptracegrpc.NewClient(
|
||||
otlptracegrpc.WithEndpoint(cfg.Endpoint),
|
||||
)
|
||||
return otlptrace.New(context.Background(), client)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// createPropagator 创建传播器
|
||||
func createPropagator(propagatorType string) propagation.TextMapPropagator {
|
||||
switch propagatorType {
|
||||
case "w3c":
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
default:
|
||||
return propagation.TraceContext{}
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭链路追踪
|
||||
func Close(ctx context.Context) error {
|
||||
if tracerProvider != nil {
|
||||
return tracerProvider.Shutdown(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Middleware Gin 中间件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动为每个请求创建 Span,记录关键信息
|
||||
func Middleware(serviceName string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从请求中提取 TraceContext
|
||||
ctx := otel.GetTextMapPropagator().Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
|
||||
|
||||
// 创建 Span
|
||||
spanName := c.Request.Method + " " + c.FullPath()
|
||||
if spanName == "" {
|
||||
spanName = c.Request.Method + " " + c.Request.URL.Path
|
||||
}
|
||||
|
||||
ctx, span := tracer.Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(
|
||||
semconv.HTTPRequestMethodKey.String(c.Request.Method),
|
||||
semconv.URLPathKey.String(c.Request.URL.Path),
|
||||
semconv.HTTPRouteKey.String(c.FullPath()),
|
||||
attribute.String("http.user_agent", c.Request.UserAgent()),
|
||||
attribute.String("http.host", c.Request.Host),
|
||||
),
|
||||
)
|
||||
|
||||
// 将 context 存入 Gin Context
|
||||
c.Set("otel_ctx", ctx)
|
||||
|
||||
// 将 TraceID 添加到响应头
|
||||
traceID := span.SpanContext().TraceID().String()
|
||||
c.Header("X-Trace-ID", traceID)
|
||||
|
||||
// 执行请求
|
||||
c.Next()
|
||||
|
||||
// 设置 Span 状态
|
||||
status := c.Writer.Status()
|
||||
span.SetAttributes(semconv.HTTPResponseStatusCodeKey.Int(status))
|
||||
|
||||
if status >= 400 {
|
||||
span.SetStatus(codes.Error, http.StatusText(status))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
// 结束 Span
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
|
||||
// GetContext 从 Gin Context 获取 OpenTelemetry Context
|
||||
func GetContext(c *gin.Context) context.Context {
|
||||
if ctx, exists := c.Get("otel_ctx"); exists {
|
||||
return ctx.(context.Context)
|
||||
}
|
||||
return c.Request.Context()
|
||||
}
|
||||
|
||||
// GetTraceID 获取当前 TraceID
|
||||
func GetTraceID(c *gin.Context) string {
|
||||
ctx := GetContext(c)
|
||||
span := trace.SpanFromContext(ctx)
|
||||
if span.SpanContext().HasTraceID() {
|
||||
return span.SpanContext().TraceID().String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StartSpan 创建子 Span
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 用于记录业务逻辑中的关键操作
|
||||
func StartSpan(c *gin.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) {
|
||||
ctx := GetContext(c)
|
||||
return tracer.Start(ctx, name,
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(attrs...),
|
||||
)
|
||||
}
|
||||
|
||||
// StartSpanFromContext 从 Context 创建 Span
|
||||
func StartSpanFromContext(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) {
|
||||
return tracer.Start(ctx, name,
|
||||
trace.WithSpanKind(trace.SpanKindInternal),
|
||||
trace.WithAttributes(attrs...),
|
||||
)
|
||||
}
|
||||
|
||||
// RecordError 记录错误
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动将错误信息关联到 Span
|
||||
func RecordError(c *gin.Context, err error) {
|
||||
ctx := GetContext(c)
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
}
|
||||
|
||||
// RecordErrorToSpan 记录错误到指定 Span
|
||||
func RecordErrorToSpan(span trace.Span, err error) {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
}
|
||||
|
||||
// AddAttributes 添加属性到当前 Span
|
||||
func AddAttributes(c *gin.Context, attrs ...attribute.KeyValue) {
|
||||
ctx := GetContext(c)
|
||||
span := trace.SpanFromContext(ctx)
|
||||
span.SetAttributes(attrs...)
|
||||
}
|
||||
|
||||
// GetTracer 获取全局 Tracer
|
||||
func GetTracer() trace.Tracer {
|
||||
return tracer
|
||||
}
|
||||
|
||||
// SetAttribute 设置单个属性
|
||||
func SetAttribute(c *gin.Context, key string, value any) {
|
||||
ctx := GetContext(c)
|
||||
span := trace.SpanFromContext(ctx)
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
span.SetAttributes(attribute.String(key, v))
|
||||
case int:
|
||||
span.SetAttributes(attribute.Int(key, v))
|
||||
case int64:
|
||||
span.SetAttributes(attribute.Int64(key, v))
|
||||
case bool:
|
||||
span.SetAttributes(attribute.Bool(key, v))
|
||||
case float64:
|
||||
span.SetAttributes(attribute.Float64(key, v))
|
||||
default:
|
||||
span.SetAttributes(attribute.String(key, fmt.Sprintf("%v", v)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ToInt 字符串转 int,失败返回 0
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 简化类型转换,避免每次处理错误
|
||||
func ToInt(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
// ToIntDefault 字符串转 int,失败返回默认值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 提供默认值,更灵活
|
||||
func ToIntDefault(s string, def int) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ToInt64 字符串转 int64,失败返回 0
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 大整数转换常用
|
||||
func ToInt64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// ToInt64Default 字符串转 int64,失败返回默认值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 提供默认值,更灵活
|
||||
func ToInt64Default(s string, def int64) int64 {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ToUint64 字符串转 uint64,失败返回 0
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 无符号整数转换
|
||||
func ToUint64(s string) uint64 {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// ToUint64Default 字符串转 uint64,失败返回默认值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 提供默认值
|
||||
func ToUint64Default(s string, def uint64) uint64 {
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ToFloat64 字符串转 float64,失败返回 0
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 浮点数转换常用
|
||||
func ToFloat64(s string) float64 {
|
||||
n, _ := strconv.ParseFloat(s, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// ToFloat64Default 字符串转 float64,失败返回默认值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 提供默认值
|
||||
func ToFloat64Default(s string, def float64) float64 {
|
||||
n, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ToString 整数转字符串
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 简化 strconv.Itoa 调用
|
||||
func ToString(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// ToString64 int64 转字符串
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 大整数转字符串
|
||||
func ToString64(n int64) string {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
|
||||
// CalcPageCount 计算总页数
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 分页查询必备,避免除零错误
|
||||
func CalcPageCount(total, pageSize int64) int64 {
|
||||
if total <= 0 || pageSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (total + pageSize - 1) / pageSize
|
||||
}
|
||||
|
||||
// CalcOffset 计算分页偏移量
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 分页查询必备,自动处理页码边界
|
||||
func CalcOffset(page, pageSize int) int {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
)
|
||||
|
||||
// MD5 计算字符串的 MD5 哈希值
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 常用哈希函数,但 MD5 已不安全,仅用于非安全场景(如文件校验)
|
||||
// 注意: 不应用于密码存储
|
||||
func MD5(s string) string {
|
||||
return MD5Bytes([]byte(s))
|
||||
}
|
||||
|
||||
// MD5Bytes 计算字节数组的 MD5 哈希值
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 文件哈希常用
|
||||
func MD5Bytes(data []byte) string {
|
||||
h := md5.New()
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SHA1 计算字符串的 SHA1 哈希值
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: SHA1 也已不安全,仅用于兼容旧系统
|
||||
// 注意: 不应用于密码存储
|
||||
func SHA1(s string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(s))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SHA256 计算字符串的 SHA256 哈希值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 安全哈希算法,适用于数据完整性校验
|
||||
func SHA256(s string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(s))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SHA256Bytes 计算字节数组的 SHA256 哈希值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 文件哈希常用
|
||||
func SHA256Bytes(data []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// HashFile 计算文件的哈希值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 大文件哈希,支持分块读取避免内存溢出
|
||||
func HashFile(path string, newHash func() hash.Hash) (string, error) {
|
||||
data, err := ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := newHash()
|
||||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Base64Encode Base64 编码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用编码函数
|
||||
func Base64Encode(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Base64Decode Base64 解码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用解码函数
|
||||
func Base64Decode(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// Base64URLEncode URL 安全的 Base64 编码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: URL 传输常用,替换 +/ 为 -_
|
||||
func Base64URLEncode(data []byte) string {
|
||||
return base64.URLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// Base64URLDecode URL 安全的 Base64 解码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: URL 传输常用
|
||||
func Base64URLDecode(s string) ([]byte, error) {
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// Nl2br 将换行符替换为 <br> 标签
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: 特定场景使用(富文本显示),但现代前端框架通常自行处理
|
||||
func Nl2br(s string, isXhtml bool) string {
|
||||
var br string
|
||||
if isXhtml {
|
||||
br = "<br />"
|
||||
} else {
|
||||
br = "<br>"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
runes := []rune(s)
|
||||
length := len(runes)
|
||||
|
||||
for i, r := range runes {
|
||||
switch r {
|
||||
case '\n':
|
||||
// 检查是否是 \r\n 或 \n\r
|
||||
if i+1 < length {
|
||||
next := runes[i+1]
|
||||
if (r == '\n' && next == '\r') || (r == '\r' && next == '\n') {
|
||||
buf.WriteString(br)
|
||||
continue
|
||||
}
|
||||
}
|
||||
buf.WriteString(br)
|
||||
case '\r':
|
||||
// 单独的 \r 或 \r\n 已在上面处理
|
||||
if i+1 < length && runes[i+1] == '\n' {
|
||||
continue // \r\n 由 \n 处理
|
||||
}
|
||||
buf.WriteString(br)
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NowUnix 返回当前秒级时间戳
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 简单封装,语义清晰,避免每次写 time.Now().Unix()
|
||||
func NowUnix() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// NowTimestamp 返回当前毫秒时间戳
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: API接口常用毫秒时间戳,封装后更易用
|
||||
func NowTimestamp() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
|
||||
// FromUnix 秒级时间戳转 time.Time
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 时间戳转换常用,封装后语义清晰
|
||||
func FromUnix(unix int64) time.Time {
|
||||
return time.Unix(unix, 0)
|
||||
}
|
||||
|
||||
// FromTimestamp 毫秒时间戳转 time.Time
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 毫秒时间戳转换,API交互常用
|
||||
func FromTimestamp(timestamp int64) time.Time {
|
||||
return time.UnixMilli(timestamp)
|
||||
}
|
||||
|
||||
// FormatTime 格式化时间
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 常用时间格式化封装,支持自定义布局
|
||||
func FormatTime(t time.Time, layout string) string {
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ParseTime 解析时间字符串
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 时间解析封装,统一错误处理
|
||||
func ParseTime(timeStr, layout string) (time.Time, error) {
|
||||
return time.Parse(layout, timeStr)
|
||||
}
|
||||
|
||||
// FormatDateTime 格式化为标准日期时间 "2006-01-02 15:04:05"
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 预设常用格式,避免记忆 Go 的时间布局
|
||||
func FormatDateTime(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// FormatDate 格式化为日期 "2006-01-02"
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 预设常用格式
|
||||
func FormatDate(t time.Time) string {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// FormatTimeOnly 格式化为时间 "15:04:05"
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 预设常用格式
|
||||
func FormatTimeOnly(t time.Time) string {
|
||||
return t.Format("15:04:05")
|
||||
}
|
||||
|
||||
// StartOfDay 返回指定时间当天的开始时间 (00:00:00)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 统计报表、日期范围查询常用
|
||||
func StartOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// EndOfDay 返回指定时间当天的结束时间 (23:59:59.999999999)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 统计报表、日期范围查询常用
|
||||
func EndOfDay(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
|
||||
}
|
||||
|
||||
// StartOfWeek 返回指定时间当周的开始时间(周一为第一天)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 周报表统计常用
|
||||
func StartOfWeek(t time.Time) time.Time {
|
||||
weekday := int(t.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
d := time.Duration(weekday-1) * 24 * time.Hour
|
||||
return StartOfDay(t.Add(-d))
|
||||
}
|
||||
|
||||
// StartOfMonth 返回指定时间当月的开始时间
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 月度统计常用
|
||||
func StartOfMonth(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// EndOfMonth 返回指定时间当月的结束时间
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 月度统计常用
|
||||
func EndOfMonth(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location())
|
||||
}
|
||||
|
||||
// GetDateInt 返回 yyyyMMdd 格式的日期整数
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: 特定场景使用(如分区键),但返回 int 可能不够通用
|
||||
func GetDateInt(t time.Time) int {
|
||||
ret, _ := strconv.Atoi(t.Format("20060102"))
|
||||
return ret
|
||||
}
|
||||
|
||||
// ParseDateInt 将 yyyyMMdd 格式的整数转为时间
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: GetDateInt 的反向操作
|
||||
func ParseDateInt(date int) time.Time {
|
||||
year := date / 10000
|
||||
month := (date % 10000) / 100
|
||||
day := date % 100
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// FileExists 检查文件是否存在
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用检查,语义清晰
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DirExists 检查目录是否存在
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 区分文件和目录检查,更精确
|
||||
func DirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.IsDir()
|
||||
}
|
||||
|
||||
// EnsureDir 确保目录存在,不存在则创建
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 日志、上传等场景常用,避免每次判断
|
||||
func EnsureDir(path string) error {
|
||||
return os.MkdirAll(path, 0755)
|
||||
}
|
||||
|
||||
// ReadFile 读取文件内容
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 简化 os.ReadFile,增加存在性检查
|
||||
func ReadFile(path string) ([]byte, error) {
|
||||
if !FileExists(path) {
|
||||
return nil, fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
// WriteFile 写入文件内容(覆盖)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 简化文件写入,自动创建目录
|
||||
func WriteFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// AppendFile 追加内容到文件
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 日志追加场景常用
|
||||
func AppendFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// CopyFile 复制文件
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 文件操作常用,使用 io.Copy 高效复制
|
||||
func CopyFile(dst, src string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// 确保目标目录存在
|
||||
dir := filepath.Dir(dst)
|
||||
if err := EnsureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
// FileSize 获取文件大小
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 上传文件大小检查常用
|
||||
func FileSize(path string) (int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// RemoveFile 删除文件(忽略不存在的错误)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 清理临时文件时常用,避免额外判断
|
||||
func RemoveFile(path string) error {
|
||||
err := os.Remove(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPClient HTTP 客户端封装
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式调用设计优雅,Transport 复用,连接池优化
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
transport *http.Transport
|
||||
timeout time.Duration
|
||||
headers map[string]string
|
||||
cookies map[string]string
|
||||
skipTLS bool
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// UploadFile 上传文件信息
|
||||
type UploadFile struct {
|
||||
FieldName string // 表单字段名
|
||||
FilePath string // 文件路径
|
||||
}
|
||||
|
||||
// HTTPClientConfig HTTP 客户端配置
|
||||
type HTTPClientConfig struct {
|
||||
Timeout time.Duration // 请求超时时间
|
||||
MaxIdleConns int // 最大空闲连接数
|
||||
IdleConnTimeout time.Duration // 空闲连接超时时间
|
||||
MaxConnsPerHost int // 每个主机最大连接数
|
||||
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
|
||||
SkipTLSVerify bool // 是否跳过 TLS 验证
|
||||
}
|
||||
|
||||
// DefaultHTTPClientConfig 默认配置
|
||||
var DefaultHTTPClientConfig = HTTPClientConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
MaxConnsPerHost: 10,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
SkipTLSVerify: true, // 开发环境默认跳过
|
||||
}
|
||||
|
||||
// NewHTTPClient 创建 HTTP 客户端
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: Transport 在初始化时创建,连接池可复用
|
||||
func NewHTTPClient() *HTTPClient {
|
||||
cfg := DefaultHTTPClientConfig
|
||||
return NewHTTPClientWithConfig(cfg)
|
||||
}
|
||||
|
||||
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
|
||||
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||||
// Transport 在初始化时创建,连接池可复用
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: cfg.SkipTLSVerify,
|
||||
},
|
||||
MaxIdleConns: cfg.MaxIdleConns,
|
||||
IdleConnTimeout: cfg.IdleConnTimeout,
|
||||
MaxConnsPerHost: cfg.MaxConnsPerHost,
|
||||
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
client: client,
|
||||
transport: transport,
|
||||
timeout: cfg.Timeout,
|
||||
headers: make(map[string]string),
|
||||
cookies: make(map[string]string),
|
||||
skipTLS: cfg.SkipTLSVerify,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimeout 设置超时时间
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式调用,动态调整超时
|
||||
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
|
||||
c.timeout = timeout
|
||||
c.client.Timeout = timeout
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeader 设置请求头
|
||||
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||||
c.headers[key] = value
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeaders 批量设置请求头
|
||||
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||||
for k, v := range headers {
|
||||
c.headers[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// SetCookie 设置 Cookie
|
||||
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||||
c.cookies[key] = value
|
||||
return c
|
||||
}
|
||||
|
||||
// SetSkipTLS 设置是否跳过 TLS 验证
|
||||
// 注意: 修改 TLS 配置需要重新创建 Transport
|
||||
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||||
c.skipTLS = skip
|
||||
c.transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: skip,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Get 发送 GET 请求
|
||||
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
|
||||
if len(params) > 0 {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
urlStr = u.String()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Post 发送 POST 请求(form 表单格式)
|
||||
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
|
||||
data := url.Values{}
|
||||
for k, v := range params {
|
||||
data.Set(k, v)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// PostJSON 发送 POST 请求(JSON 格式)
|
||||
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Put 发送 PUT 请求(JSON 格式)
|
||||
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", urlStr, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Delete 发送 DELETE 请求
|
||||
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||||
req, err := http.NewRequest("DELETE", urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
for _, f := range files {
|
||||
file, err := os.Open(f.FilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = io.Copy(part, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// UploadFromBytes 从字节数据上传文件
|
||||
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
|
||||
part, err := writer.CreateFormFile(fieldName, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = io.Copy(part, bytes.NewReader(data)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range params {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// Request 发送自定义请求
|
||||
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return c.do(req)
|
||||
}
|
||||
|
||||
// do 执行请求(使用共享的 client 和 transport)
|
||||
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||||
// 设置请求头
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// 设置 Cookie
|
||||
for k, v := range c.cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
// 发送请求(使用初始化时创建的 client)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查状态码
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// DoWithResponse 执行请求并返回完整响应
|
||||
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
for k, v := range c.cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// Close 关闭客户端(释放连接池资源)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 清理资源,避免连接泄漏
|
||||
func (c *HTTPClient) Close() {
|
||||
c.transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// JSONMarshal 内部 JSON 序列化函数
|
||||
func JSONMarshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// 全局默认 HTTP 客户端
|
||||
var defaultClient *HTTPClient
|
||||
var defaultClientOnce sync.Once
|
||||
|
||||
// DefaultHTTPClient 获取全局默认 HTTP 客户端
|
||||
func DefaultHTTPClient() *HTTPClient {
|
||||
defaultClientOnce.Do(func() {
|
||||
defaultClient = NewHTTPClient()
|
||||
})
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// HTTPGet 使用默认客户端发送 GET 请求
|
||||
func HTTPGet(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Get(url, params)
|
||||
}
|
||||
|
||||
// HTTPPost 使用默认客户端发送 POST 请求
|
||||
func HTTPPost(url string, params map[string]string) ([]byte, error) {
|
||||
return DefaultHTTPClient().Post(url, params)
|
||||
}
|
||||
|
||||
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
|
||||
func HTTPPostJSON(url string, data any) ([]byte, error) {
|
||||
return DefaultHTTPClient().PostJSON(url, data)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
randPool = sync.Pool{
|
||||
New: func() any {
|
||||
return rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
digitBytes = "0123456789"
|
||||
letterIdxBits = 6 // 6 bits to represent a letter index (0-63)
|
||||
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
|
||||
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
|
||||
)
|
||||
|
||||
// RandString 生成指定长度的随机字符串(字母+数字)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 sync.Pool 复用 rand.Source,性能优秀;位运算优化高效
|
||||
func RandString(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
b := make([]byte, n)
|
||||
r := randPool.Get().(*rand.Rand)
|
||||
defer randPool.Put(r)
|
||||
|
||||
for i, cache, remain := n-1, r.Int63(), letterIdxMax; i >= 0; {
|
||||
if remain == 0 {
|
||||
cache, remain = r.Int63(), letterIdxMax
|
||||
}
|
||||
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
|
||||
b[i] = letterBytes[idx]
|
||||
i--
|
||||
}
|
||||
cache >>= letterIdxBits
|
||||
remain--
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RandDigit 生成指定长度的随机数字字符串
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 同 RandString,适用于验证码、订单号等场景
|
||||
func RandDigit(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
b := make([]byte, n)
|
||||
r := randPool.Get().(*rand.Rand)
|
||||
defer randPool.Put(r)
|
||||
|
||||
for i, cache, remain := n-1, r.Int63(), letterIdxMax; i >= 0; {
|
||||
if remain == 0 {
|
||||
cache, remain = r.Int63(), letterIdxMax
|
||||
}
|
||||
if idx := int(cache & letterIdxMask); idx < len(digitBytes) {
|
||||
b[i] = digitBytes[idx]
|
||||
i--
|
||||
}
|
||||
cache >>= letterIdxBits
|
||||
remain--
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RandInt 返回 [min, max) 范围内的随机整数
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 实用函数,自动处理 min > max 的情况
|
||||
func RandInt(min, max int) int {
|
||||
if min == max {
|
||||
return min
|
||||
}
|
||||
if max < min {
|
||||
min, max = max, min
|
||||
}
|
||||
r := randPool.Get().(*rand.Rand)
|
||||
defer randPool.Put(r)
|
||||
return min + r.Intn(max-min)
|
||||
}
|
||||
|
||||
// RandInt64 返回 [min, max) 范围内的随机 int64
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: RandInt 的 int64 版本,适用于大范围随机数
|
||||
func RandInt64(min, max int64) int64 {
|
||||
if min == max {
|
||||
return min
|
||||
}
|
||||
if max < min {
|
||||
min, max = max, min
|
||||
}
|
||||
r := randPool.Get().(*rand.Rand)
|
||||
defer randPool.Put(r)
|
||||
return min + r.Int63n(max-min)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// IsBlank 检查字符串是否为空或仅包含空白字符
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用函数,正确处理 Unicode 空白字符
|
||||
func IsBlank(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, r := range s {
|
||||
if !unicode.IsSpace(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsNotBlank 检查字符串是否非空且不全是空白字符
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: IsBlank 的反向,语义更清晰
|
||||
func IsNotBlank(s string) bool {
|
||||
return !IsBlank(s)
|
||||
}
|
||||
|
||||
// IsAnyBlank 检查多个字符串中是否有任意一个为空或空白
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 参数校验场景常用,避免多次调用 IsBlank
|
||||
func IsAnyBlank(strs ...string) bool {
|
||||
for _, s := range strs {
|
||||
if IsBlank(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsAllBlank 检查所有字符串是否都为空或空白
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: IsAnyBlank 的反向,适用于批量校验
|
||||
func IsAllBlank(strs ...string) bool {
|
||||
for _, s := range strs {
|
||||
if IsNotBlank(s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DefaultIfBlank 如果字符串为空或空白,返回默认值
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 配置默认值场景常用,代码简洁
|
||||
func DefaultIfBlank(s, def string) string {
|
||||
if IsBlank(s) {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// IsEmpty 检查任意类型是否为空值
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: 功能实用,但使用反射有性能开销;建议仅在必要时使用
|
||||
// 支持: string, []T, map[K]V, nil, 零值
|
||||
func IsEmpty(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val == ""
|
||||
case []byte:
|
||||
return len(val) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Trim 去除字符串首尾的空白字符(包括空格、制表符、换行符)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 比标准库 strings.Trim 更语义化,预设常用空白字符
|
||||
func Trim(s string) string {
|
||||
return trimFunc(s, unicode.IsSpace)
|
||||
}
|
||||
|
||||
// trimFunc 内部修剪函数
|
||||
func trimFunc(s string, f func(rune) bool) string {
|
||||
s = trimLeftFunc(s, f)
|
||||
return trimRightFunc(s, f)
|
||||
}
|
||||
|
||||
func trimLeftFunc(s string, f func(rune) bool) string {
|
||||
for i, r := range s {
|
||||
if !f(r) {
|
||||
return s[i:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func trimRightFunc(s string, f func(rune) bool) string {
|
||||
for i := len(s); i > 0; {
|
||||
r, size := utf8.DecodeLastRuneInString(s[:i])
|
||||
if !f(r) {
|
||||
return s[:i]
|
||||
}
|
||||
i -= size
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Substr 截取子字符串(按 Unicode 字符计数)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 正确处理多字节字符(中文等),避免截断
|
||||
// 参数: start 起始位置(支持负数从末尾计算),length 截取长度
|
||||
func Substr(s string, start, length int) string {
|
||||
runes := []rune(s)
|
||||
lenRunes := len(runes)
|
||||
|
||||
if lenRunes == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 处理负数起始位置
|
||||
if start < 0 {
|
||||
start = lenRunes + start
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= lenRunes {
|
||||
return ""
|
||||
}
|
||||
|
||||
end := start + length
|
||||
if end > lenRunes {
|
||||
end = lenRunes
|
||||
}
|
||||
if end <= start {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(runes[start:end])
|
||||
}
|
||||
|
||||
// StrLen 获取字符串的 Unicode 字符数
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 正确计算多字节字符长度,比 len() 更准确
|
||||
func StrLen(s string) int {
|
||||
return utf8.RuneCountInString(s)
|
||||
}
|
||||
|
||||
// EqualsIgnoreCase 不区分大小写比较字符串
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 常用比较函数,使用标准库实现保证正确性
|
||||
func EqualsIgnoreCase(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(a); i++ {
|
||||
ca := a[i]
|
||||
cb := b[i]
|
||||
if ca != cb {
|
||||
// 转小写比较
|
||||
if ca >= 'A' && ca <= 'Z' {
|
||||
ca += 32
|
||||
}
|
||||
if cb >= 'A' && cb <= 'Z' {
|
||||
cb += 32
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// URLBuilder URL 构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式调用设计优雅,便于构建复杂 URL
|
||||
type URLBuilder struct {
|
||||
u *url.URL
|
||||
query url.Values
|
||||
}
|
||||
|
||||
// ParseURL 解析 URL 字符串
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 创建 URLBuilder 的入口函数
|
||||
func ParseURL(rawURL string) (*URLBuilder, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &URLBuilder{
|
||||
u: u,
|
||||
query: u.Query(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddQuery 添加单个查询参数
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式调用,支持多次添加
|
||||
func (b *URLBuilder) AddQuery(key, value string) *URLBuilder {
|
||||
b.query.Add(key, value)
|
||||
return b
|
||||
}
|
||||
|
||||
// AddQueries 批量添加查询参数
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量添加更高效
|
||||
func (b *URLBuilder) AddQueries(params map[string]string) *URLBuilder {
|
||||
for k, v := range params {
|
||||
b.query.Set(k, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetQuery 设置查询参数(覆盖同名参数)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 区别于 Add,覆盖同名参数
|
||||
func (b *URLBuilder) SetQuery(key, value string) *URLBuilder {
|
||||
b.query.Set(key, value)
|
||||
return b
|
||||
}
|
||||
|
||||
// SetPath 设置路径
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 动态修改路径
|
||||
func (b *URLBuilder) SetPath(path string) *URLBuilder {
|
||||
b.u.Path = path
|
||||
return b
|
||||
}
|
||||
|
||||
// SetScheme 设置协议(http/https)
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 协议切换
|
||||
func (b *URLBuilder) SetScheme(scheme string) *URLBuilder {
|
||||
b.u.Scheme = scheme
|
||||
return b
|
||||
}
|
||||
|
||||
// SetHost 设置主机
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 域名切换
|
||||
func (b *URLBuilder) SetHost(host string) *URLBuilder {
|
||||
b.u.Host = host
|
||||
return b
|
||||
}
|
||||
|
||||
// Build 构建最终的 URL
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 返回标准库 url.URL
|
||||
func (b *URLBuilder) Build() *url.URL {
|
||||
b.u.RawQuery = b.query.Encode()
|
||||
return b.u
|
||||
}
|
||||
|
||||
// String 返回 URL 字符串
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 最常用的输出方法
|
||||
func (b *URLBuilder) String() string {
|
||||
return b.Build().String()
|
||||
}
|
||||
|
||||
// URLEncode URL 编码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用编码函数
|
||||
func URLEncode(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
|
||||
// URLDecode URL 解码
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用解码函数
|
||||
func URLDecode(s string) (string, error) {
|
||||
return url.QueryUnescape(s)
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package utils_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
)
|
||||
|
||||
// ===== Random Tests =====
|
||||
|
||||
func TestRandString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
length int
|
||||
}{
|
||||
{"normal", 16},
|
||||
{"short", 1},
|
||||
{"long", 100},
|
||||
{"zero", 0},
|
||||
{"negative", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := utils.RandString(tt.length)
|
||||
if tt.length <= 0 {
|
||||
if s != "" {
|
||||
t.Errorf("RandString(%d) should return empty", tt.length)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(s) != tt.length {
|
||||
t.Errorf("RandString(%d) length = %d", tt.length, len(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 测试 uniqueness
|
||||
results := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
s := utils.RandString(16)
|
||||
if results[s] {
|
||||
t.Error("RandString generated duplicate")
|
||||
}
|
||||
results[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandDigit(t *testing.T) {
|
||||
s := utils.RandDigit(6)
|
||||
if len(s) != 6 {
|
||||
t.Errorf("RandDigit length = %d", len(s))
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
t.Errorf("RandDigit contains non-digit: %c", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandInt(t *testing.T) {
|
||||
// 正常范围
|
||||
for i := 0; i < 100; i++ {
|
||||
r := utils.RandInt(1, 100)
|
||||
if r < 1 || r >= 100 {
|
||||
t.Errorf("RandInt(1, 100) = %d, out of range", r)
|
||||
}
|
||||
}
|
||||
|
||||
// min == max
|
||||
r := utils.RandInt(5, 5)
|
||||
if r != 5 {
|
||||
t.Errorf("RandInt(5, 5) = %d", r)
|
||||
}
|
||||
|
||||
// min > max (自动交换)
|
||||
r = utils.RandInt(100, 1)
|
||||
if r < 1 || r >= 100 {
|
||||
t.Errorf("RandInt(100, 1) = %d, should swap", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandInt64(t *testing.T) {
|
||||
r := utils.RandInt64(0, 1000000)
|
||||
if r < 0 || r >= 1000000 {
|
||||
t.Errorf("RandInt64 out of range: %d", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== String Tests =====
|
||||
|
||||
func TestIsBlank(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"", true},
|
||||
{" ", true},
|
||||
{"\t\n", true},
|
||||
{"abc", false},
|
||||
{" abc ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if utils.IsBlank(tt.input) != tt.expected {
|
||||
t.Errorf("IsBlank(%q) = %v, want %v", tt.input, !tt.expected, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnyBlank(t *testing.T) {
|
||||
if !utils.IsAnyBlank("", "a") {
|
||||
t.Error("IsAnyBlank should return true")
|
||||
}
|
||||
if utils.IsAnyBlank("a", "b") {
|
||||
t.Error("IsAnyBlank should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllBlank(t *testing.T) {
|
||||
if !utils.IsAllBlank("", " ", "\t") {
|
||||
t.Error("IsAllBlank should return true")
|
||||
}
|
||||
if utils.IsAllBlank("", "a") {
|
||||
t.Error("IsAllBlank should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultIfBlank(t *testing.T) {
|
||||
if utils.DefaultIfBlank("", "def") != "def" {
|
||||
t.Error("DefaultIfBlank empty failed")
|
||||
}
|
||||
if utils.DefaultIfBlank("val", "def") != "val" {
|
||||
t.Error("DefaultIfBlank non-empty failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstr(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
start int
|
||||
length int
|
||||
expected string
|
||||
}{
|
||||
{"hello世界", 0, 7, "hello世界"},
|
||||
{"hello世界", 0, 5, "hello"},
|
||||
{"hello世界", 5, 2, "世界"},
|
||||
{"hello世界", -2, 2, "世界"},
|
||||
{"hello世界", 100, 2, ""},
|
||||
{"", 0, 5, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := utils.Substr(tt.input, tt.start, tt.length)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Substr(%q, %d, %d) = %q, want %q", tt.input, tt.start, tt.length, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrLen(t *testing.T) {
|
||||
if utils.StrLen("hello世界") != 7 {
|
||||
t.Error("StrLen Unicode count failed")
|
||||
}
|
||||
if utils.StrLen("") != 0 {
|
||||
t.Error("StrLen empty failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqualsIgnoreCase(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
expected bool
|
||||
}{
|
||||
{"ABC", "abc", true},
|
||||
{"Hello", "HELLO", true},
|
||||
{"abc", "abc", true},
|
||||
{"abc", "abd", false},
|
||||
{"ab", "abc", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if utils.EqualsIgnoreCase(tt.a, tt.b) != tt.expected {
|
||||
t.Errorf("EqualsIgnoreCase(%q, %q) failed", tt.a, tt.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrim(t *testing.T) {
|
||||
if utils.Trim(" hello ") != "hello" {
|
||||
t.Error("Trim failed")
|
||||
}
|
||||
if utils.Trim("\t\nhello\n\t") != "hello" {
|
||||
t.Error("Trim whitespace failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DateTime Tests =====
|
||||
|
||||
func TestNowUnix(t *testing.T) {
|
||||
n := utils.NowUnix()
|
||||
if n == 0 {
|
||||
t.Error("NowUnix returned 0")
|
||||
}
|
||||
// 应接近当前时间
|
||||
expected := time.Now().Unix()
|
||||
if n < expected-1 || n > expected+1 {
|
||||
t.Errorf("NowUnix = %d, expected ~%d", n, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNowTimestamp(t *testing.T) {
|
||||
n := utils.NowTimestamp()
|
||||
if n == 0 {
|
||||
t.Error("NowTimestamp returned 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromUnix(t *testing.T) {
|
||||
now := time.Now()
|
||||
unix := now.Unix()
|
||||
result := utils.FromUnix(unix)
|
||||
if result.Unix() != unix {
|
||||
t.Errorf("FromUnix mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDateTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := utils.FormatDateTime(now)
|
||||
if len(s) != 19 {
|
||||
t.Errorf("FormatDateTime length = %d", len(s))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := utils.FormatDate(now)
|
||||
if len(s) != 10 {
|
||||
t.Errorf("FormatDate length = %d", len(s))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartEndOfDay(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := utils.StartOfDay(now)
|
||||
end := utils.EndOfDay(now)
|
||||
|
||||
if start.Hour() != 0 || start.Minute() != 0 || start.Second() != 0 {
|
||||
t.Error("StartOfDay not at 00:00:00")
|
||||
}
|
||||
if end.Hour() != 23 || end.Minute() != 59 || end.Second() != 59 {
|
||||
t.Error("EndOfDay not at 23:59:59")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartEndOfMonth(t *testing.T) {
|
||||
now := time.Now()
|
||||
start := utils.StartOfMonth(now)
|
||||
end := utils.EndOfMonth(now)
|
||||
|
||||
if start.Day() != 1 {
|
||||
t.Error("StartOfMonth day != 1")
|
||||
}
|
||||
if end.Day() < 28 || end.Day() > 31 {
|
||||
t.Errorf("EndOfMonth day = %d, unexpected", end.Day())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDateInt(t *testing.T) {
|
||||
now := time.Now()
|
||||
dateInt := utils.GetDateInt(now)
|
||||
expected := now.Year()*10000 + int(now.Month())*100 + now.Day()
|
||||
if dateInt != expected {
|
||||
t.Errorf("GetDateInt = %d, expected %d", dateInt, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateInt(t *testing.T) {
|
||||
dateInt := 20260430
|
||||
result := utils.ParseDateInt(dateInt)
|
||||
if result.Year() != 2026 || result.Month() != 4 || result.Day() != 30 {
|
||||
t.Errorf("ParseDateInt(%d) = %v", dateInt, result)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Convert Tests =====
|
||||
|
||||
func TestToInt(t *testing.T) {
|
||||
if utils.ToInt("123") != 123 {
|
||||
t.Error("ToInt failed")
|
||||
}
|
||||
if utils.ToInt("abc") != 0 {
|
||||
t.Error("ToInt invalid should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToIntDefault(t *testing.T) {
|
||||
if utils.ToIntDefault("123", 999) != 123 {
|
||||
t.Error("ToIntDefault valid failed")
|
||||
}
|
||||
if utils.ToIntDefault("abc", 999) != 999 {
|
||||
t.Error("ToIntDefault invalid should return default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToInt64(t *testing.T) {
|
||||
if utils.ToInt64("1234567890123") != 1234567890123 {
|
||||
t.Error("ToInt64 failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToInt64Default(t *testing.T) {
|
||||
if utils.ToInt64Default("abc", 999) != 999 {
|
||||
t.Error("ToInt64Default failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUint64Default(t *testing.T) {
|
||||
if utils.ToUint64Default("abc", 999) != 999 {
|
||||
t.Error("ToUint64Default failed")
|
||||
}
|
||||
if utils.ToUint64Default("123", 0) != 123 {
|
||||
t.Error("ToUint64Default valid failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFloat64(t *testing.T) {
|
||||
if utils.ToFloat64("3.14") != 3.14 {
|
||||
t.Error("ToFloat64 failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFloat64Default(t *testing.T) {
|
||||
if utils.ToFloat64Default("abc", 1.5) != 1.5 {
|
||||
t.Error("ToFloat64Default failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToString(t *testing.T) {
|
||||
if utils.ToString(123) != "123" {
|
||||
t.Error("ToString failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToString64(t *testing.T) {
|
||||
if utils.ToString64(1234567890123) != "1234567890123" {
|
||||
t.Error("ToString64 failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcPageCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
total int64
|
||||
pageSize int64
|
||||
expected int64
|
||||
}{
|
||||
{100, 10, 10},
|
||||
{95, 10, 10},
|
||||
{0, 10, 0},
|
||||
{100, 0, 0},
|
||||
{1, 10, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := utils.CalcPageCount(tt.total, tt.pageSize)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CalcPageCount(%d, %d) = %d, want %d", tt.total, tt.pageSize, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
page int
|
||||
pageSize int
|
||||
expected int
|
||||
}{
|
||||
{1, 20, 0},
|
||||
{2, 20, 20},
|
||||
{3, 10, 20},
|
||||
{0, 20, 0}, // page <= 0 自动修正为 1
|
||||
{1, 0, 0}, // pageSize <= 0 自动修正为 20
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := utils.CalcOffset(tt.page, tt.pageSize)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CalcOffset(%d, %d) = %d, want %d", tt.page, tt.pageSize, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Validator Tests =====
|
||||
|
||||
func TestIsPhone(t *testing.T) {
|
||||
valid := []string{"13812345678", "15912345678", "18812345678"}
|
||||
invalid := []string{"12345678901", "1381234567", "138123456789"}
|
||||
|
||||
for _, p := range valid {
|
||||
if !utils.IsPhone(p) {
|
||||
t.Errorf("IsPhone(%s) should be true", p)
|
||||
}
|
||||
}
|
||||
for _, p := range invalid {
|
||||
if utils.IsPhone(p) {
|
||||
t.Errorf("IsPhone(%s) should be false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmail(t *testing.T) {
|
||||
valid := []string{"test@example.com", "user.name@domain.cn"}
|
||||
invalid := []string{"invalid", "no@", "@nodomain.com"}
|
||||
|
||||
for _, e := range valid {
|
||||
if !utils.IsEmail(e) {
|
||||
t.Errorf("IsEmail(%s) should be true", e)
|
||||
}
|
||||
}
|
||||
for _, e := range invalid {
|
||||
if utils.IsEmail(e) {
|
||||
t.Errorf("IsEmail(%s) should be false", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIPv4(t *testing.T) {
|
||||
valid := []string{"192.168.1.1", "0.0.0.0", "255.255.255.255"}
|
||||
invalid := []string{"256.1.1.1", "1.1.1", "1.1.1.1.1"}
|
||||
|
||||
for _, ip := range valid {
|
||||
if !utils.IsIPv4(ip) {
|
||||
t.Errorf("IsIPv4(%s) should be true", ip)
|
||||
}
|
||||
}
|
||||
for _, ip := range invalid {
|
||||
if utils.IsIPv4(ip) {
|
||||
t.Errorf("IsIPv4(%s) should be false", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIDCard(t *testing.T) {
|
||||
// 18位身份证
|
||||
if !utils.IsIDCard("11010519900307293X") {
|
||||
t.Error("IsIDCard valid 18-digit failed")
|
||||
}
|
||||
// 无效
|
||||
if utils.IsIDCard("123456789012345") {
|
||||
t.Error("IsIDCard invalid should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsChinese(t *testing.T) {
|
||||
if !utils.IsChinese("中文") {
|
||||
t.Error("IsChinese should be true")
|
||||
}
|
||||
if utils.IsChinese("abc中文") {
|
||||
t.Error("IsChinese mixed should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNumeric(t *testing.T) {
|
||||
if !utils.IsNumeric("12345") {
|
||||
t.Error("IsNumeric should be true")
|
||||
}
|
||||
if utils.IsNumeric("123a") {
|
||||
t.Error("IsNumeric should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlphanumeric(t *testing.T) {
|
||||
if !utils.IsAlphanumeric("abc123") {
|
||||
t.Error("IsAlphanumeric should be true")
|
||||
}
|
||||
if utils.IsAlphanumeric("abc-123") {
|
||||
t.Error("IsAlphanumeric with dash should be false")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Crypto Tests =====
|
||||
|
||||
func TestMD5(t *testing.T) {
|
||||
if utils.MD5("hello") != "5d41402abc4b2a76b9719d911017c592" {
|
||||
t.Error("MD5 failed")
|
||||
}
|
||||
if utils.MD5("") != "d41d8cd98f00b204e9800998ecf8427e" {
|
||||
t.Error("MD5 empty failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSHA256(t *testing.T) {
|
||||
// SHA256 有固定长度
|
||||
hash := utils.SHA256("hello")
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("SHA256 length = %d", len(hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64(t *testing.T) {
|
||||
original := "hello world"
|
||||
encoded := utils.Base64Encode([]byte(original))
|
||||
decoded, err := utils.Base64Decode(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("Base64Decode error: %v", err)
|
||||
}
|
||||
if string(decoded) != original {
|
||||
t.Errorf("Base64 roundtrip failed: %s", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64URL(t *testing.T) {
|
||||
original := "hello+world"
|
||||
encoded := utils.Base64URLEncode([]byte(original))
|
||||
decoded, err := utils.Base64URLDecode(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("Base64URLDecode error: %v", err)
|
||||
}
|
||||
if string(decoded) != original {
|
||||
t.Errorf("Base64URL roundtrip failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== UUID Tests =====
|
||||
|
||||
func TestUUID(t *testing.T) {
|
||||
uuid := utils.UUID()
|
||||
if len(uuid) != 36 {
|
||||
t.Errorf("UUID length = %d", len(uuid))
|
||||
}
|
||||
// 格式检查: 8-4-4-4-12
|
||||
if uuid[8] != '-' || uuid[13] != '-' || uuid[18] != '-' || uuid[23] != '-' {
|
||||
t.Errorf("UUID format invalid: %s", uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDShort(t *testing.T) {
|
||||
uuid := utils.UUIDShort()
|
||||
if len(uuid) != 32 {
|
||||
t.Errorf("UUIDShort length = %d", len(uuid))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDValid(t *testing.T) {
|
||||
valid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
invalid := "invalid-uuid"
|
||||
|
||||
if !utils.UUIDValid(valid) {
|
||||
t.Errorf("UUIDValid(%s) should be true", valid)
|
||||
}
|
||||
if utils.UUIDValid(invalid) {
|
||||
t.Errorf("UUIDValid(%s) should be false", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== URL Tests =====
|
||||
|
||||
func TestURLEncodeDecode(t *testing.T) {
|
||||
original := "hello world"
|
||||
encoded := utils.URLEncode(original)
|
||||
decoded, err := utils.URLDecode(encoded)
|
||||
if err != nil {
|
||||
t.Errorf("URLDecode error: %v", err)
|
||||
}
|
||||
if decoded != original {
|
||||
t.Errorf("URL roundtrip failed: %s -> %s -> %s", original, encoded, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseURL(t *testing.T) {
|
||||
b, err := utils.ParseURL("https://example.com/path")
|
||||
if err != nil {
|
||||
t.Errorf("ParseURL error: %v", err)
|
||||
}
|
||||
result := b.AddQuery("key", "value").AddQuery("foo", "bar").String()
|
||||
if !contains(result, "key=value") || !contains(result, "foo=bar") {
|
||||
t.Errorf("URLBuilder result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsHelper(s, sub))
|
||||
}
|
||||
|
||||
func containsHelper(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ===== File Tests =====
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
if utils.FileExists("/nonexistent/path") {
|
||||
t.Error("FileExists should return false for nonexistent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirExists(t *testing.T) {
|
||||
if utils.DirExists("/nonexistent/dir") {
|
||||
t.Error("DirExists should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Benchmarks =====
|
||||
|
||||
func BenchmarkRandString(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.RandString(16)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRandDigit(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.RandDigit(6)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMD5(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.MD5("hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSHA256(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.SHA256("hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUUID(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.UUID()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStrLen(b *testing.B) {
|
||||
s := "hello世界测试字符串"
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.StrLen(s)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCalcPageCount(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
utils.CalcPageCount(10000, 20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// UUID 生成 UUID v4 字符串
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 常用唯一标识生成,使用标准库 google/uuid
|
||||
func UUID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// UUIDShort 生成短 UUID(无横线)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 数据库主键、订单号等场景常用
|
||||
func UUIDShort() string {
|
||||
return uuid.New().String()[:32]
|
||||
}
|
||||
|
||||
// UUIDParse 解析 UUID 字符串
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: UUID 解析验证
|
||||
func UUIDParse(s string) (uuid.UUID, error) {
|
||||
return uuid.Parse(s)
|
||||
}
|
||||
|
||||
// UUIDValid 检查 UUID 字符串是否有效
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 快速验证
|
||||
func UUIDValid(s string) bool {
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// IsPhone 检查是否为有效的中国大陆手机号
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 常用验证,但正则需要随运营商号段更新
|
||||
// 注意: 正则基于当前号段,新号段开放时需更新
|
||||
func IsPhone(phone string) bool {
|
||||
// 1开头,第二位为3-9,共11位
|
||||
pattern := `^1[3-9]\d{9}$`
|
||||
matched, _ := regexp.MatchString(pattern, phone)
|
||||
return matched
|
||||
}
|
||||
|
||||
// IsEmail 检查是否为有效的邮箱地址
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 常用验证,简单正则覆盖大部分场景
|
||||
func IsEmail(email string) bool {
|
||||
// 简单邮箱验证:xxx@xxx.xxx
|
||||
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
matched, _ := regexp.MatchString(pattern, email)
|
||||
return matched
|
||||
}
|
||||
|
||||
// IsIPv4 检查是否为有效的 IPv4 地址
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 网络编程常用验证
|
||||
func IsIPv4(ip string) bool {
|
||||
pattern := `^(\d{1,3}\.){3}\d{1,3}$`
|
||||
matched, _ := regexp.MatchString(pattern, ip)
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
// 验证每个段在 0-255 范围内
|
||||
parts := splitByDot(ip)
|
||||
for _, part := range parts {
|
||||
n := ToInt(part)
|
||||
if n < 0 || n > 255 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsIDCard 检查是否为有效的中国身份证号(18位)
|
||||
// 评分: ⭐⭐⭐
|
||||
// 理由: 身份证验证需求常见,但校验位算法复杂,此为简化版
|
||||
// 注意: 仅校验格式,不校验校验位
|
||||
func IsIDCard(id string) bool {
|
||||
// 18位身份证:6位地区码 + 8位生日 + 3位顺序码 + 1位校验码
|
||||
pattern := `^\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$`
|
||||
matched, _ := regexp.MatchString(pattern, id)
|
||||
return matched
|
||||
}
|
||||
|
||||
// IsChinese 检查字符串是否全部为中文字符
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 中文内容校验常用
|
||||
func IsChinese(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < 0x4E00 || r > 0x9FFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// HasChinese 检查字符串是否包含中文字符
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 混合内容检测
|
||||
func HasChinese(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsNumeric 检查字符串是否全部为数字
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 简单高效的数字检测
|
||||
func IsNumeric(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAlpha 检查字符串是否全部为字母
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 字母检测
|
||||
func IsAlpha(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAlphanumeric 检查字符串是否全部为字母或数字
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 用户名、密码等常用验证
|
||||
func IsAlphanumeric(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 内部函数
|
||||
func splitByDot(s string) []string {
|
||||
var result []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' {
|
||||
result = append(result, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
result = append(result, s[start:])
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// 默认加密成本(bcrypt 推荐值)
|
||||
const defaultCost = 12
|
||||
|
||||
// HashPassword 对密码进行加密
|
||||
func HashPassword(password string) (string, error) {
|
||||
return HashPasswordWithCost(password, defaultCost)
|
||||
}
|
||||
|
||||
// HashPasswordWithCost 使用指定成本对密码进行加密
|
||||
// cost 范围: 4-31,值越大越安全但越慢
|
||||
func HashPasswordWithCost(password string, cost int) (string, error) {
|
||||
if cost < bcrypt.MinCost {
|
||||
cost = bcrypt.MinCost
|
||||
}
|
||||
if cost > bcrypt.MaxCost {
|
||||
cost = bcrypt.MaxCost
|
||||
}
|
||||
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码是否匹配
|
||||
// hashedPassword 是加密后的密码,password 是明文密码
|
||||
func CheckPassword(hashedPassword, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// CheckPasswordAndUpgrade 验证密码并在需要时升级加密成本
|
||||
// 返回:是否匹配、是否需要升级、升级后的密码、错误
|
||||
func CheckPasswordAndUpgrade(hashedPassword, password string, targetCost int) (match bool, needUpgrade bool, newHash string, err error) {
|
||||
// 验证密码
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
// 检查是否需要升级
|
||||
cost, err := bcrypt.Cost([]byte(hashedPassword))
|
||||
if err != nil {
|
||||
return true, false, "", nil
|
||||
}
|
||||
|
||||
if cost < targetCost {
|
||||
newHash, err = HashPasswordWithCost(password, targetCost)
|
||||
if err != nil {
|
||||
return true, false, "", err
|
||||
}
|
||||
return true, true, newHash, nil
|
||||
}
|
||||
|
||||
return true, false, "", nil
|
||||
}
|
||||
|
||||
// GetPasswordCost 获取加密密码的成本
|
||||
func GetPasswordCost(hashedPassword string) (int, error) {
|
||||
return bcrypt.Cost([]byte(hashedPassword))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// PasswordConfig 密码验证配置
|
||||
type PasswordConfig struct {
|
||||
MinLength int // 最小长度
|
||||
MaxLength int // 最大长度
|
||||
RequireUpper bool // 需要大写字母
|
||||
RequireLower bool // 需要小写字母
|
||||
RequireDigit bool // 需要数字
|
||||
RequireSpecial bool // 需要特殊字符
|
||||
}
|
||||
|
||||
// DefaultPasswordConfig 默认密码配置
|
||||
var DefaultPasswordConfig = PasswordConfig{
|
||||
MinLength: 8, // 最少8位
|
||||
MaxLength: 128, // 最多128位
|
||||
RequireUpper: true,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
RequireSpecial: false,
|
||||
}
|
||||
|
||||
// ValidatePassword 验证密码强度
|
||||
// 返回:是否有效,错误信息
|
||||
func ValidatePassword(password string) (bool, string) {
|
||||
return ValidatePasswordWithConfig(password, DefaultPasswordConfig)
|
||||
}
|
||||
|
||||
// ValidatePasswordWithConfig 使用指定配置验证密码强度
|
||||
func ValidatePasswordWithConfig(password string, config PasswordConfig) (bool, string) {
|
||||
length := len(password)
|
||||
|
||||
// 检查最小长度
|
||||
if length < config.MinLength {
|
||||
return false, "密码长度不能少于" + strconv.Itoa(config.MinLength) + "位"
|
||||
}
|
||||
|
||||
// 检查最大长度
|
||||
if length > config.MaxLength {
|
||||
return false, "密码长度不能超过" + strconv.Itoa(config.MaxLength) + "位"
|
||||
}
|
||||
|
||||
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查大写字母
|
||||
if config.RequireUpper && !hasUpper {
|
||||
return false, "密码必须包含大写字母"
|
||||
}
|
||||
|
||||
// 检查小写字母
|
||||
if config.RequireLower && !hasLower {
|
||||
return false, "密码必须包含小写字母"
|
||||
}
|
||||
|
||||
// 检查数字
|
||||
if config.RequireDigit && !hasDigit {
|
||||
return false, "密码必须包含数字"
|
||||
}
|
||||
|
||||
// 检查特殊字符
|
||||
if config.RequireSpecial && !hasSpecial {
|
||||
return false, "密码必须包含特殊字符"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package validation_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/validation"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ===== Password Tests =====
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
password string
|
||||
valid bool
|
||||
msg string
|
||||
}{
|
||||
{"Abc12345", true, ""}, // 有效
|
||||
{"abc12345", false, "密码必须包含大写字母"}, // 缺大写
|
||||
{"ABC12345", false, "密码必须包含小写字母"}, // 缺小写
|
||||
{"Abcdefgh", false, "密码必须包含数字"}, // 缺数字
|
||||
{"Abc123", false, "密码长度不能少于8位"}, // 太短
|
||||
{"", false, "密码长度不能少于8位"}, // 空
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
valid, msg := validation.ValidatePassword(tt.password)
|
||||
if valid != tt.valid {
|
||||
t.Errorf("ValidatePassword(%s) valid = %v, want %v", tt.password, valid, tt.valid)
|
||||
}
|
||||
if !valid && msg != tt.msg {
|
||||
t.Errorf("ValidatePassword(%s) msg = %s, want %s", tt.password, msg, tt.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePasswordWithConfig(t *testing.T) {
|
||||
// 自定义配置:不要求特殊字符
|
||||
config := validation.PasswordConfig{
|
||||
MinLength: 6,
|
||||
MaxLength: 20,
|
||||
RequireUpper: false,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
RequireSpecial: false,
|
||||
}
|
||||
|
||||
valid, msg := validation.ValidatePasswordWithConfig("abc123", config)
|
||||
if !valid {
|
||||
t.Errorf("ValidatePasswordWithConfig should be valid: %s", msg)
|
||||
}
|
||||
|
||||
// 要求特殊字符
|
||||
config2 := validation.PasswordConfig{
|
||||
MinLength: 8,
|
||||
MaxLength: 20,
|
||||
RequireUpper: true,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
RequireSpecial: true,
|
||||
}
|
||||
|
||||
valid2, msg2 := validation.ValidatePasswordWithConfig("Abc12345", config2)
|
||||
if valid2 {
|
||||
t.Error("Should require special character")
|
||||
}
|
||||
if msg2 != "密码必须包含特殊字符" {
|
||||
t.Errorf("msg = %s, want '密码必须包含特殊字符'", msg2)
|
||||
}
|
||||
|
||||
// 包含特殊字符
|
||||
valid3, msg3 := validation.ValidatePasswordWithConfig("Abc123!@#", config2)
|
||||
if !valid3 {
|
||||
t.Errorf("Should be valid: %s", msg3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPasswordConfig(t *testing.T) {
|
||||
cfg := validation.DefaultPasswordConfig
|
||||
if cfg.MinLength != 8 {
|
||||
t.Errorf("MinLength = %d, want 8", cfg.MinLength)
|
||||
}
|
||||
if cfg.MaxLength != 128 {
|
||||
t.Errorf("MaxLength = %d, want 128", cfg.MaxLength)
|
||||
}
|
||||
if !cfg.RequireUpper || !cfg.RequireLower || !cfg.RequireDigit {
|
||||
t.Error("Default config should require upper, lower, digit")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Hash Password Tests =====
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
|
||||
hash, err := validation.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 hash 不等于原密码
|
||||
if hash == password {
|
||||
t.Error("Hash should not equal original password")
|
||||
}
|
||||
|
||||
// 验证 hash 非空
|
||||
if hash == "" {
|
||||
t.Error("Hash should not be empty")
|
||||
}
|
||||
|
||||
// 验证可以匹配
|
||||
if !validation.CheckPassword(hash, password) {
|
||||
t.Error("CheckPassword should return true for correct password")
|
||||
}
|
||||
|
||||
// 验证错误密码不匹配
|
||||
if validation.CheckPassword(hash, "wrongPassword") {
|
||||
t.Error("CheckPassword should return false for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordWithCost(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
|
||||
// 测试不同 cost
|
||||
hash, err := validation.HashPasswordWithCost(password, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPasswordWithCost error: %v", err)
|
||||
}
|
||||
|
||||
cost, err := validation.GetPasswordCost(hash)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPasswordCost error: %v", err)
|
||||
}
|
||||
if cost != 10 {
|
||||
t.Errorf("Cost = %d, want 10", cost)
|
||||
}
|
||||
|
||||
// 测试 cost 边界
|
||||
hash2, err := validation.HashPasswordWithCost(password, 3) // 低于 MinCost
|
||||
if err != nil {
|
||||
t.Fatalf("HashPasswordWithCost with low cost error: %v", err)
|
||||
}
|
||||
cost2, _ := validation.GetPasswordCost(hash2)
|
||||
if cost2 < bcrypt.MinCost {
|
||||
t.Errorf("Cost should be at least MinCost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPasswordAndUpgrade(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
|
||||
// 使用低 cost 创建 hash
|
||||
hash, _ := validation.HashPasswordWithCost(password, 4)
|
||||
|
||||
// 检查并尝试升级
|
||||
match, needUpgrade, newHash, err := validation.CheckPasswordAndUpgrade(hash, password, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPasswordAndUpgrade error: %v", err)
|
||||
}
|
||||
|
||||
if !match {
|
||||
t.Error("Password should match")
|
||||
}
|
||||
|
||||
if !needUpgrade {
|
||||
t.Error("Should need upgrade (cost 4 -> 12)")
|
||||
}
|
||||
|
||||
if newHash == "" {
|
||||
t.Error("New hash should be provided")
|
||||
}
|
||||
|
||||
// 验证新 hash 可用
|
||||
if !validation.CheckPassword(newHash, password) {
|
||||
t.Error("New hash should work")
|
||||
}
|
||||
|
||||
// 使用高 cost,不需要升级
|
||||
hash2, _ := validation.HashPasswordWithCost(password, 12)
|
||||
match2, needUpgrade2, _, _ := validation.CheckPasswordAndUpgrade(hash2, password, 12)
|
||||
if !match2 {
|
||||
t.Error("Password should match")
|
||||
}
|
||||
if needUpgrade2 {
|
||||
t.Error("Should not need upgrade")
|
||||
}
|
||||
|
||||
// 错误密码
|
||||
match3, _, _, _ := validation.CheckPasswordAndUpgrade(hash, "wrongPassword", 12)
|
||||
if match3 {
|
||||
t.Error("Wrong password should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPasswordCost(t *testing.T) {
|
||||
password := "testPassword123"
|
||||
hash, _ := validation.HashPasswordWithCost(password, 12)
|
||||
|
||||
cost, err := validation.GetPasswordCost(hash)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPasswordCost error: %v", err)
|
||||
}
|
||||
if cost != 12 {
|
||||
t.Errorf("Cost = %d, want 12", cost)
|
||||
}
|
||||
|
||||
// 无效 hash
|
||||
_, err = validation.GetPasswordCost("invalidHash")
|
||||
if err == nil {
|
||||
t.Error("GetPasswordCost should fail with invalid hash")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Validation Errors Tests =====
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
errors := validation.ValidationErrors{
|
||||
{Field: "name", Label: "姓名", Message: "必填"},
|
||||
{Field: "email", Label: "邮箱", Message: "格式错误"},
|
||||
}
|
||||
|
||||
// Error 方法
|
||||
errStr := errors.Error()
|
||||
if errStr != "姓名: 必填; 邮箱: 格式错误" {
|
||||
t.Errorf("Error() = %s", errStr)
|
||||
}
|
||||
|
||||
// ToMap 方法
|
||||
m := errors.ToMap()
|
||||
if m["name"] != "必填" {
|
||||
t.Error("ToMap failed")
|
||||
}
|
||||
|
||||
// ToLabelMap 方法
|
||||
lm := errors.ToLabelMap()
|
||||
if lm["姓名"] != "必填" {
|
||||
t.Error("ToLabelMap failed")
|
||||
}
|
||||
|
||||
// First 方法
|
||||
first := errors.First()
|
||||
if first.Field != "name" {
|
||||
t.Error("First failed")
|
||||
}
|
||||
|
||||
// FirstMessage 方法
|
||||
msg := errors.FirstMessage()
|
||||
if msg != "必填" {
|
||||
t.Errorf("FirstMessage = %s", msg)
|
||||
}
|
||||
|
||||
// 空 errors
|
||||
emptyErrors := validation.ValidationErrors{}
|
||||
if emptyErrors.First() != nil {
|
||||
t.Error("Empty First should return nil")
|
||||
}
|
||||
if emptyErrors.FirstMessage() != "" {
|
||||
t.Error("Empty FirstMessage should return empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Struct Validation Tests =====
|
||||
|
||||
type TestUser struct {
|
||||
Name string `json:"name" label:"姓名" binding:"required" msg_required:"姓名不能为空"`
|
||||
Email string `json:"email" label:"邮箱" binding:"required,email" msg_required:"邮箱不能为空" msg_email:"邮箱格式不正确"`
|
||||
Age int `json:"age" label:"年龄" binding:"gte=0,lte=150"`
|
||||
Password string `json:"password" binding:"min=8" msg_min:"密码至少8位"`
|
||||
}
|
||||
|
||||
func TestValidateStruct(t *testing.T) {
|
||||
validation.InitValidator()
|
||||
|
||||
// 有效数据
|
||||
validUser := TestUser{
|
||||
Name: "张三",
|
||||
Email: "test@example.com",
|
||||
Age: 25,
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
errors := validation.ValidateStruct(validUser)
|
||||
if errors != nil {
|
||||
t.Errorf("Valid struct should have no errors: %v", errors)
|
||||
}
|
||||
|
||||
// 无效数据
|
||||
invalidUser := TestUser{
|
||||
Name: "",
|
||||
Email: "invalid-email",
|
||||
Age: 200,
|
||||
Password: "short",
|
||||
}
|
||||
|
||||
errors2 := validation.ValidateStruct(invalidUser)
|
||||
if errors2 == nil {
|
||||
t.Error("Invalid struct should have errors")
|
||||
}
|
||||
|
||||
// 检查错误数量
|
||||
if len(errors2) < 3 {
|
||||
t.Errorf("Should have at least 3 errors, got %d", len(errors2))
|
||||
}
|
||||
|
||||
// 检查自定义消息
|
||||
firstMsg := errors2.FirstMessage()
|
||||
if firstMsg != "姓名不能为空" {
|
||||
t.Errorf("FirstMessage = %s, want '姓名不能为空'", firstMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructNil(t *testing.T) {
|
||||
// 空结构体
|
||||
emptyUser := TestUser{}
|
||||
errors := validation.ValidateStruct(emptyUser)
|
||||
if errors == nil {
|
||||
t.Error("Empty struct should have errors")
|
||||
}
|
||||
}
|
||||
|
||||
type TestPhone struct {
|
||||
Phone string `json:"phone" binding:"phone" msg_phone:"手机号格式不正确"`
|
||||
}
|
||||
|
||||
func TestValidatePhone(t *testing.T) {
|
||||
validation.InitValidator()
|
||||
|
||||
// 有效手机号
|
||||
valid := TestPhone{Phone: "13812345678"}
|
||||
errors := validation.ValidateStruct(valid)
|
||||
if errors != nil {
|
||||
t.Errorf("Valid phone should pass: %v", errors)
|
||||
}
|
||||
|
||||
// 无效手机号 - 长度错误
|
||||
invalidLen := TestPhone{Phone: "1234567"}
|
||||
errors2 := validation.ValidateStruct(invalidLen)
|
||||
if errors2 == nil {
|
||||
t.Error("Invalid phone length should fail")
|
||||
}
|
||||
|
||||
// 无效手机号 - 不以1开头
|
||||
invalidPrefix := TestPhone{Phone: "23812345678"}
|
||||
errors3 := validation.ValidateStruct(invalidPrefix)
|
||||
if errors3 == nil {
|
||||
t.Error("Phone not starting with 1 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
type TestUsername struct {
|
||||
Username string `json:"username" binding:"username" msg_username:"用户名格式不正确"`
|
||||
}
|
||||
|
||||
func TestValidateUsername(t *testing.T) {
|
||||
validation.InitValidator()
|
||||
|
||||
tests := []struct {
|
||||
username string
|
||||
valid bool
|
||||
}{
|
||||
{"abc123", true}, // 有效
|
||||
{"Abc123", true}, // 有效(大写开头)
|
||||
{"user_name", true}, // 有效(包含下划线)
|
||||
{"123abc", false}, // 无效(数字开头)
|
||||
{"ab", false}, // 无效(太短)
|
||||
{"a!bc", false}, // 无效(特殊字符)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
u := TestUsername{Username: tt.username}
|
||||
errors := validation.ValidateStruct(u)
|
||||
valid := errors == nil
|
||||
if valid != tt.valid {
|
||||
t.Errorf("Username %s: valid=%v, want %v", tt.username, valid, tt.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Benchmarks =====
|
||||
|
||||
func BenchmarkHashPassword(b *testing.B) {
|
||||
password := "testPassword123"
|
||||
for i := 0; i < b.N; i++ {
|
||||
validation.HashPassword(password)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCheckPassword(b *testing.B) {
|
||||
password := "testPassword123"
|
||||
hash, _ := validation.HashPassword(password)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
validation.CheckPassword(hash, password)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidatePassword(b *testing.B) {
|
||||
password := "TestPassword123"
|
||||
for i := 0; i < b.N; i++ {
|
||||
validation.ValidatePassword(password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Validator 全局验证器实例
|
||||
var Validator *validator.Validate
|
||||
|
||||
// ValidationError 验证错误
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"` // 字段名(使用 label 或 json tag)
|
||||
Label string `json:"label"` // 字段中文名(用于显示)
|
||||
Message string `json:"message"` // 错误消息
|
||||
}
|
||||
|
||||
// ValidationErrors 验证错误列表
|
||||
type ValidationErrors []ValidationError
|
||||
|
||||
// Error 实现 error 接口
|
||||
func (ve ValidationErrors) Error() string {
|
||||
var msgs []string
|
||||
for _, e := range ve {
|
||||
if e.Label != "" {
|
||||
msgs = append(msgs, e.Label+": "+e.Message)
|
||||
} else {
|
||||
msgs = append(msgs, e.Field+": "+e.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// ToMap 转换为 map
|
||||
func (ve ValidationErrors) ToMap() map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, e := range ve {
|
||||
m[e.Field] = e.Message
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ToLabelMap 转换为带标签的 map
|
||||
func (ve ValidationErrors) ToLabelMap() map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, e := range ve {
|
||||
if e.Label != "" {
|
||||
m[e.Label] = e.Message
|
||||
} else {
|
||||
m[e.Field] = e.Message
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// First 获取第一个错误
|
||||
func (ve ValidationErrors) First() *ValidationError {
|
||||
if len(ve) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ve[0]
|
||||
}
|
||||
|
||||
// FirstMessage 获取第一个错误消息
|
||||
func (ve ValidationErrors) FirstMessage() string {
|
||||
if len(ve) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ve[0].Message
|
||||
}
|
||||
|
||||
// InitValidator 初始化验证器
|
||||
func InitValidator() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
Validator = v
|
||||
|
||||
// 注册自定义标签名函数(优先使用 label,其次 json)
|
||||
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
// 优先使用 label tag 作为字段显示名
|
||||
label := fld.Tag.Get("label")
|
||||
if label != "" {
|
||||
return label
|
||||
}
|
||||
|
||||
// 其次使用 json tag
|
||||
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
// 注册自定义验证规则
|
||||
registerCustomValidations(v)
|
||||
}
|
||||
}
|
||||
|
||||
// registerCustomValidations 注册自定义验证规则
|
||||
func registerCustomValidations(v *validator.Validate) {
|
||||
// 密码强度验证
|
||||
v.RegisterValidation("password", func(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
valid, _ := ValidatePassword(password)
|
||||
return valid
|
||||
})
|
||||
|
||||
// 手机号验证(中国大陆)
|
||||
v.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if len(phone) != 11 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(phone, "1")
|
||||
})
|
||||
|
||||
// 用户名验证(字母开头,允许字母数字下划线)
|
||||
v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
username := fl.Field().String()
|
||||
if len(username) < 3 || len(username) > 20 {
|
||||
return false
|
||||
}
|
||||
if !isLetter(rune(username[0])) {
|
||||
return false
|
||||
}
|
||||
for _, r := range username {
|
||||
if !isLetter(r) && !isDigit(r) && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 手机号严格验证(验证运营商号段)
|
||||
v.RegisterValidation("phone_strict", func(fl validator.FieldLevel) bool {
|
||||
phone := fl.Field().String()
|
||||
if len(phone) != 11 {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(phone, "1") {
|
||||
return false
|
||||
}
|
||||
// 检查号段
|
||||
prefix := phone[:3]
|
||||
validPrefixes := []string{
|
||||
"130", "131", "132", "133", "134", "135", "136", "137", "138", "139",
|
||||
"145", "146", "147", "148", "149",
|
||||
"150", "151", "152", "153", "155", "156", "157", "158", "159",
|
||||
"166", "167",
|
||||
"170", "171", "172", "173", "174", "175", "176", "177", "178",
|
||||
"180", "181", "182", "183", "184", "185", "186", "187", "188", "189",
|
||||
"191", "198", "199",
|
||||
}
|
||||
for _, p := range validPrefixes {
|
||||
if prefix == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 身份证号验证(简化版)
|
||||
v.RegisterValidation("idcard", func(fl validator.FieldLevel) bool {
|
||||
id := fl.Field().String()
|
||||
if len(id) != 18 && len(id) != 15 {
|
||||
return false
|
||||
}
|
||||
// 简化验证:只检查长度和基本格式
|
||||
for i, c := range id {
|
||||
if i == len(id)-1 && len(id) == 18 {
|
||||
// 最后一位可以是 X
|
||||
if !isDigit(c) && c != 'X' && c != 'x' {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if !isDigit(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func isLetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
func isDigit(r rune) bool {
|
||||
return r >= '0' && r <= '9'
|
||||
}
|
||||
|
||||
// ValidateStruct 验证结构体
|
||||
func ValidateStruct(s any) ValidationErrors {
|
||||
if Validator == nil {
|
||||
InitValidator()
|
||||
}
|
||||
|
||||
err := Validator.Struct(s)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parseValidationErrors(err, s)
|
||||
}
|
||||
|
||||
// parseValidationErrors 解析验证错误(支持自定义错误消息)
|
||||
func parseValidationErrors(err error, s any) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
|
||||
if validationErrors, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, e := range validationErrors {
|
||||
fieldName := e.Field()
|
||||
label := fieldName // Field() 返回的是 label 或 json tag
|
||||
|
||||
// 尝试获取原始字段名和自定义错误消息
|
||||
if s != nil {
|
||||
t := reflect.TypeOf(s)
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() == reflect.Struct {
|
||||
// 获取原始字段名
|
||||
originalField := getOriginalFieldName(t, e.StructField())
|
||||
if originalField != "" {
|
||||
fieldName = originalField
|
||||
}
|
||||
|
||||
// 获取自定义错误消息
|
||||
field, found := t.FieldByName(e.StructField())
|
||||
if found {
|
||||
customMsg := getCustomErrorMessage(field, e.Tag())
|
||||
if customMsg != "" {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Label: label,
|
||||
Message: customMsg,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Label: label,
|
||||
Message: getErrorMessage(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// getOriginalFieldName 获取原始字段名(从 json tag)
|
||||
func getOriginalFieldName(t reflect.Type, structField string) string {
|
||||
field, found := t.FieldByName(structField)
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
return structField
|
||||
}
|
||||
|
||||
name := strings.SplitN(jsonTag, ",", 2)[0]
|
||||
if name == "" {
|
||||
return structField
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// getCustomErrorMessage 获取自定义错误消息
|
||||
// 支持格式:
|
||||
// - error:"自定义错误消息"
|
||||
// - msg_required:"必填项" (针对特定验证规则)
|
||||
// - msg_min:"最少5个字符"
|
||||
func getCustomErrorMessage(field reflect.StructField, tag string) string {
|
||||
// 优先查找特定规则的错误消息
|
||||
specificTag := fmt.Sprintf("msg_%s", tag)
|
||||
msg := field.Tag.Get(specificTag)
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
|
||||
// 其次查找通用错误消息
|
||||
msg = field.Tag.Get("error")
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
|
||||
// 最后查找 msg tag
|
||||
msg = field.Tag.Get("msg")
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// getErrorMessage 获取默认验证错误消息
|
||||
func getErrorMessage(e validator.FieldError) string {
|
||||
switch e.Tag() {
|
||||
case "required":
|
||||
return "此字段为必填项"
|
||||
case "email":
|
||||
return "邮箱格式不正确"
|
||||
case "min":
|
||||
return fmt.Sprintf("长度不能少于 %s 个字符", e.Param())
|
||||
case "max":
|
||||
return fmt.Sprintf("长度不能超过 %s 个字符", e.Param())
|
||||
case "len":
|
||||
return fmt.Sprintf("长度必须为 %s 个字符", e.Param())
|
||||
case "gte":
|
||||
return fmt.Sprintf("必须大于或等于 %s", e.Param())
|
||||
case "lte":
|
||||
return fmt.Sprintf("必须小于或等于 %s", e.Param())
|
||||
case "gt":
|
||||
return fmt.Sprintf("必须大于 %s", e.Param())
|
||||
case "lt":
|
||||
return fmt.Sprintf("必须小于 %s", e.Param())
|
||||
case "eq":
|
||||
return fmt.Sprintf("必须等于 %s", e.Param())
|
||||
case "ne":
|
||||
return fmt.Sprintf("不能等于 %s", e.Param())
|
||||
case "oneof":
|
||||
return fmt.Sprintf("必须是以下值之一: %s", e.Param())
|
||||
case "url":
|
||||
return "URL 格式不正确"
|
||||
case "uri":
|
||||
return "URI 格式不正确"
|
||||
case "uuid":
|
||||
return "UUID 格式不正确"
|
||||
case "alphanum":
|
||||
return "只能包含字母和数字"
|
||||
case "alpha":
|
||||
return "只能包含字母"
|
||||
case "numeric":
|
||||
return "必须是数字"
|
||||
case "password":
|
||||
return "密码强度不足,需包含大小写字母和数字,至少8位"
|
||||
case "phone":
|
||||
return "手机号格式不正确"
|
||||
case "phone_strict":
|
||||
return "手机号无效,请输入正确的手机号"
|
||||
case "username":
|
||||
return "用户名必须以字母开头,只能包含字母、数字和下划线,长度3-20"
|
||||
case "idcard":
|
||||
return "身份证号格式不正确"
|
||||
default:
|
||||
return fmt.Sprintf("验证失败: %s", e.Tag())
|
||||
}
|
||||
}
|
||||
|
||||
// BindAndValidate 绑定并验证请求
|
||||
func BindAndValidate(c *gin.Context, req any) ValidationErrors {
|
||||
if err := c.ShouldBind(req); err != nil {
|
||||
return parseValidationErrors(err, req)
|
||||
}
|
||||
return ValidateStruct(req)
|
||||
}
|
||||
|
||||
// ShouldBindAndValidate 绑定并验证请求,返回是否成功
|
||||
func ShouldBindAndValidate(c *gin.Context, req any) (ValidationErrors, bool) {
|
||||
errors := BindAndValidate(c, req)
|
||||
return errors, len(errors) == 0
|
||||
}
|
||||
|
||||
// BindJSON 绑定 JSON 并验证
|
||||
func BindJSON(c *gin.Context, req any) ValidationErrors {
|
||||
if err := c.ShouldBindJSON(req); err != nil {
|
||||
return parseValidationErrors(err, req)
|
||||
}
|
||||
return ValidateStruct(req)
|
||||
}
|
||||
|
||||
// BindQuery 绑定 Query 并验证
|
||||
func BindQuery(c *gin.Context, req any) ValidationErrors {
|
||||
if err := c.ShouldBindQuery(req); err != nil {
|
||||
return parseValidationErrors(err, req)
|
||||
}
|
||||
return ValidateStruct(req)
|
||||
}
|
||||
|
||||
// BindForm 绑定 Form 并验证
|
||||
func BindForm(c *gin.Context, req any) ValidationErrors {
|
||||
if err := c.ShouldBind(req); err != nil {
|
||||
return parseValidationErrors(err, req)
|
||||
}
|
||||
return ValidateStruct(req)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
)
|
||||
|
||||
// ServiceContainer 服务容器
|
||||
type ServiceContainer struct {
|
||||
// 应用可以在这里添加自己的服务
|
||||
}
|
||||
|
||||
// 全局服务容器
|
||||
var container *ServiceContainer
|
||||
|
||||
// InitServices 初始化所有服务
|
||||
func InitServices() *ServiceContainer {
|
||||
// 初始化缓存
|
||||
cache.Init()
|
||||
|
||||
// 创建服务容器
|
||||
container = &ServiceContainer{}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
// GetContainer 获取服务容器
|
||||
func GetContainer() *ServiceContainer {
|
||||
if container == nil {
|
||||
InitServices()
|
||||
}
|
||||
return container
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebSocket 配置
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 生产环境应检查 Origin
|
||||
},
|
||||
}
|
||||
|
||||
// MessageType 消息类型
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
TypeText MessageType = "text"
|
||||
TypeBinary MessageType = "binary"
|
||||
TypePing MessageType = "ping"
|
||||
TypePong MessageType = "pong"
|
||||
TypeClose MessageType = "close"
|
||||
)
|
||||
|
||||
// Message WebSocket 消息
|
||||
type Message struct {
|
||||
Type MessageType `json:"type"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
// Connection WebSocket 连接
|
||||
type Connection struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
closeChan chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewConnection 创建 WebSocket 连接
|
||||
func NewConnection(conn *websocket.Conn) *Connection {
|
||||
return &Connection{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Send 发送消息
|
||||
func (c *Connection) Send(data []byte) error {
|
||||
select {
|
||||
case c.send <- data:
|
||||
return nil
|
||||
case <-c.closeChan:
|
||||
return errors.New("connection closed")
|
||||
}
|
||||
}
|
||||
|
||||
// SendJSON 发送 JSON 消息
|
||||
func (c *Connection) SendJSON(v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Send(data)
|
||||
}
|
||||
|
||||
// SendText 发送文本消息
|
||||
func (c *Connection) SendText(text string) error {
|
||||
return c.SendJSON(Message{Type: TypeText, Content: text})
|
||||
}
|
||||
|
||||
// Close 关闭连接
|
||||
func (c *Connection) Close() {
|
||||
c.once.Do(func() {
|
||||
close(c.closeChan)
|
||||
close(c.send)
|
||||
c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// IsClosed 检查连接是否已关闭
|
||||
func (c *Connection) IsClosed() bool {
|
||||
select {
|
||||
case <-c.closeChan:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SetReadDeadline 设置读取超时
|
||||
func (c *Connection) SetReadDeadline(t time.Time) error {
|
||||
return c.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// SetWriteDeadline 设置写入超时
|
||||
func (c *Connection) SetWriteDeadline(t time.Time) error {
|
||||
return c.conn.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
// Handler WebSocket 处理器接口
|
||||
type Handler interface {
|
||||
OnConnect(conn *Connection)
|
||||
OnMessage(conn *Connection, message []byte)
|
||||
OnClose(conn *Connection)
|
||||
OnError(conn *Connection, err error)
|
||||
}
|
||||
|
||||
// HandlerFunc 处理函数类型
|
||||
type HandlerFunc func(conn *Connection, message []byte)
|
||||
|
||||
// DefaultHandler 默认处理器
|
||||
type DefaultHandler struct {
|
||||
OnConnectFunc func(conn *Connection)
|
||||
OnMessageFunc func(conn *Connection, message []byte)
|
||||
OnCloseFunc func(conn *Connection)
|
||||
OnErrorFunc func(conn *Connection, err error)
|
||||
}
|
||||
|
||||
func (h *DefaultHandler) OnConnect(conn *Connection) {
|
||||
if h.OnConnectFunc != nil {
|
||||
h.OnConnectFunc(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DefaultHandler) OnMessage(conn *Connection, message []byte) {
|
||||
if h.OnMessageFunc != nil {
|
||||
h.OnMessageFunc(conn, message)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DefaultHandler) OnClose(conn *Connection) {
|
||||
if h.OnCloseFunc != nil {
|
||||
h.OnCloseFunc(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DefaultHandler) OnError(conn *Connection, err error) {
|
||||
if h.OnErrorFunc != nil {
|
||||
h.OnErrorFunc(conn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade 升级 HTTP 连接为 WebSocket
|
||||
func Upgrade(c *gin.Context) (*Connection, error) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewConnection(conn), nil
|
||||
}
|
||||
|
||||
// Handle WebSocket 处理中间件
|
||||
func Handle(handler Handler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
conn, err := Upgrade(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 触发连接事件
|
||||
handler.OnConnect(conn)
|
||||
|
||||
// 启动写入协程
|
||||
go writePump(conn)
|
||||
|
||||
// 读取消息
|
||||
for {
|
||||
_, message, err := conn.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
handler.OnError(conn, err)
|
||||
}
|
||||
handler.OnClose(conn)
|
||||
break
|
||||
}
|
||||
handler.OnMessage(conn, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump 写入泵
|
||||
func writePump(conn *Connection) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-conn.closeChan:
|
||||
return
|
||||
case message, ok := <-conn.send:
|
||||
if !ok {
|
||||
conn.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
conn.mu.Lock()
|
||||
err := conn.conn.WriteMessage(websocket.TextMessage, message)
|
||||
conn.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
conn.mu.Lock()
|
||||
err := conn.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
conn.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleFunc 使用函数处理 WebSocket
|
||||
func HandleFunc(fn HandlerFunc) gin.HandlerFunc {
|
||||
return Handle(&DefaultHandler{
|
||||
OnMessageFunc: fn,
|
||||
})
|
||||
}
|
||||
|
||||
// SetCheckOrigin 设置 Origin 检查函数
|
||||
func SetCheckOrigin(fn func(r *http.Request) bool) {
|
||||
upgrader.CheckOrigin = fn
|
||||
}
|
||||
|
||||
// Hub 连接管理中心(用于广播)
|
||||
type Hub struct {
|
||||
connections map[*Connection]bool
|
||||
register chan *Connection
|
||||
unregister chan *Connection
|
||||
broadcast chan []byte
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHub 创建 Hub
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
connections: make(map[*Connection]bool),
|
||||
register: make(chan *Connection),
|
||||
unregister: make(chan *Connection),
|
||||
broadcast: make(chan []byte, 256),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 运行 Hub
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case conn := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.connections[conn] = true
|
||||
h.mu.Unlock()
|
||||
|
||||
case conn := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.connections[conn]; ok {
|
||||
delete(h.connections, conn)
|
||||
conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case message := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for conn := range h.connections {
|
||||
if err := conn.Send(message); err != nil {
|
||||
h.mu.RUnlock()
|
||||
h.unregister <- conn
|
||||
h.mu.RLock()
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册连接
|
||||
func (h *Hub) Register(conn *Connection) {
|
||||
h.register <- conn
|
||||
}
|
||||
|
||||
// Unregister 注销连接
|
||||
func (h *Hub) Unregister(conn *Connection) {
|
||||
h.unregister <- conn
|
||||
}
|
||||
|
||||
// Broadcast 广播消息
|
||||
func (h *Hub) Broadcast(message []byte) {
|
||||
h.broadcast <- message
|
||||
}
|
||||
|
||||
// BroadcastJSON 广播 JSON 消息
|
||||
func (h *Hub) BroadcastJSON(v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Broadcast(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count 获取连接数
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.connections)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package ws_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/ws"
|
||||
)
|
||||
|
||||
func TestMessageType(t *testing.T) {
|
||||
if ws.TypeText != "text" {
|
||||
t.Errorf("TypeText = %s, want text", ws.TypeText)
|
||||
}
|
||||
if ws.TypeBinary != "binary" {
|
||||
t.Errorf("TypeBinary = %s, want binary", ws.TypeBinary)
|
||||
}
|
||||
if ws.TypePing != "ping" {
|
||||
t.Errorf("TypePing = %s, want ping", ws.TypePing)
|
||||
}
|
||||
if ws.TypePong != "pong" {
|
||||
t.Errorf("TypePong = %s, want pong", ws.TypePong)
|
||||
}
|
||||
if ws.TypeClose != "close" {
|
||||
t.Errorf("TypeClose = %s, want close", ws.TypeClose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage(t *testing.T) {
|
||||
msg := ws.Message{
|
||||
Type: ws.TypeText,
|
||||
Content: "hello world",
|
||||
}
|
||||
|
||||
if msg.Type != ws.TypeText {
|
||||
t.Error("Message Type failed")
|
||||
}
|
||||
if msg.Content != "hello world" {
|
||||
t.Error("Message Content failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConnection(t *testing.T) {
|
||||
// NewConnection 需要 websocket.Conn,无法直接测试
|
||||
// 仅验证结构体定义存在
|
||||
}
|
||||
|
||||
func TestNewHub(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
if hub == nil {
|
||||
t.Error("NewHub should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubCount(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
|
||||
// 无连接时计数应为 0
|
||||
count := hub.Count()
|
||||
if count != 0 {
|
||||
t.Errorf("Hub Count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubBroadcast(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
|
||||
// 广播消息(无连接时也应正常)
|
||||
hub.Broadcast([]byte("test message"))
|
||||
}
|
||||
|
||||
func TestHubBroadcastJSON(t *testing.T) {
|
||||
hub := ws.NewHub()
|
||||
|
||||
err := hub.BroadcastJSON(map[string]string{"key": "value"})
|
||||
if err != nil {
|
||||
t.Errorf("BroadcastJSON error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultHandler(t *testing.T) {
|
||||
handler := ws.DefaultHandler{}
|
||||
|
||||
// 测试空处理器不会 panic
|
||||
handler.OnConnect(nil)
|
||||
handler.OnMessage(nil, nil)
|
||||
handler.OnClose(nil)
|
||||
handler.OnError(nil, nil)
|
||||
}
|
||||
|
||||
func TestDefaultHandlerWithFuncs(t *testing.T) {
|
||||
connectCalled := false
|
||||
messageCalled := false
|
||||
|
||||
handler := ws.DefaultHandler{
|
||||
OnConnectFunc: func(conn *ws.Connection) {
|
||||
connectCalled = true
|
||||
},
|
||||
OnMessageFunc: func(conn *ws.Connection, message []byte) {
|
||||
messageCalled = true
|
||||
},
|
||||
}
|
||||
|
||||
handler.OnConnect(nil)
|
||||
handler.OnMessage(nil, nil)
|
||||
|
||||
if !connectCalled {
|
||||
t.Error("OnConnectFunc should be called")
|
||||
}
|
||||
if !messageCalled {
|
||||
t.Error("OnMessageFunc should be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerFunc(t *testing.T) {
|
||||
var handlerFunc ws.HandlerFunc = func(conn *ws.Connection, message []byte) {
|
||||
// 处理消息
|
||||
}
|
||||
|
||||
if handlerFunc == nil {
|
||||
t.Error("HandlerFunc should not be nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user