init
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 忽略项目根目录下的 .idea 文件夹
|
||||
/.idea/
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,222 @@
|
||||
# Skeleton
|
||||
|
||||
`skeleton/` 是基于 HeTianXia 项目整理出来的最小骨架目录,用来作为后续新项目的起点。
|
||||
|
||||
## 当前包含
|
||||
|
||||
- `main.go`:应用启动入口
|
||||
- `config/`:配置结构与 YAML 示例
|
||||
- `middlewares/`:日志、CORS、JWT 等基础中间件
|
||||
- `database/`:PostgreSQL 和 Redis 连接层
|
||||
- `routes/`:主路由、REST 注册器、WS 挂载点
|
||||
- `utils/`:公共工具,如时区、分页、统一响应
|
||||
- `modules/example/`:最小示例模块
|
||||
- `models/`:最小模型占位,供 Atlas 和后续模块使用
|
||||
- `atlas.hcl` 和 `atlas_loader.go`:Atlas CLI 迁移配置与模型加载入口
|
||||
- `cmd/migrate/`:迁移命令入口占位
|
||||
|
||||
## 目录说明
|
||||
|
||||
### `main.go`
|
||||
保留启动流程骨架:
|
||||
|
||||
- 加载配置
|
||||
- 初始化日志
|
||||
- 初始化 JWT
|
||||
- 初始化时区
|
||||
- 初始化数据库
|
||||
- 初始化 Redis
|
||||
- 挂载路由
|
||||
- 启动 HTTP 服务
|
||||
- 优雅关闭
|
||||
|
||||
### `models/`
|
||||
这里放最小模型占位即可。当前只保留了 `User`,用于:
|
||||
|
||||
- 让 Atlas 有可扫描的模型
|
||||
- 给后续业务模块提供基础实体
|
||||
|
||||
### `atlas_loader.go`
|
||||
这个文件的作用是告诉 Atlas CLI:
|
||||
|
||||
- 当前项目有哪些 GORM 模型
|
||||
- 这些模型对应哪些表结构
|
||||
|
||||
它不是运行时初始化文件,也不是业务逻辑文件。
|
||||
|
||||
## 下一步如何扩展
|
||||
|
||||
1. 新增业务模块时,在 `modules/` 下新建目录,例如 `modules/order`
|
||||
2. 模块内部按需添加 `controller.go`、`service.go`、`types.go`、`utils.go`
|
||||
3. 如果模型变化,先更新 `models/`,再更新 `atlas_loader.go`
|
||||
4. 用 Atlas CLI 生成和应用迁移
|
||||
|
||||
## 说明
|
||||
|
||||
这个骨架的目标不是一次性把所有能力都放齐,而是先建立稳定的项目边界:
|
||||
|
||||
- 公共能力放 `utils/`
|
||||
- HTTP 行为放 `middlewares/` 和 `routes/`
|
||||
- 数据连接放 `database/`
|
||||
- 业务放 `modules/`
|
||||
- 数据结构放 `models/`
|
||||
|
||||
## 📊 数据库迁移管理
|
||||
|
||||
本项目集成了 **Atlas + GORM Provider** 来实现自动化的数据库迁移管理。
|
||||
|
||||
### 安装 Atlas CLI
|
||||
|
||||
```bash
|
||||
# macOS (推荐)
|
||||
brew install ariga/tap/atlas
|
||||
|
||||
# 或使用 go install
|
||||
go install ariga.io/atlas/cmd/atlas@latest
|
||||
|
||||
# 验证安装
|
||||
atlas version
|
||||
```
|
||||
|
||||
### 安装 GORM Provider 依赖
|
||||
|
||||
需要单独执行,不会被`go mod tidy`命令检测到,因为构建排除了`atlas_loader.go`文件
|
||||
|
||||
```bash
|
||||
go get ariga.io/atlas-provider-gorm
|
||||
```
|
||||
|
||||
### 迁移命令
|
||||
|
||||
#### 0. 基线迁移(首次初始化)
|
||||
|
||||
如果你有一个现有的数据库需要纳入迁移管理,需要先创建基线:
|
||||
|
||||
```bash
|
||||
atlas migrate diff baseline --env local
|
||||
|
||||
atlas migrate hash --env local
|
||||
|
||||
atlas migrate set $(ls migrations/*.sql | head -1 | grep -o '[0-9]\{14\}') --env local
|
||||
```
|
||||
|
||||
⚠️ **基线迁移注意事项**:
|
||||
- **首次使用**: 如果数据库是全新的,直接使用 `apply` 命令即可
|
||||
- **现有数据库**: 必须先设置基线,告诉Atlas当前数据库的状态
|
||||
- **团队协作**: 确保所有团队成员都从同一个基线开始
|
||||
|
||||
**使用场景**:
|
||||
- **场景1 - 全新项目**: 创建数据库 → 直接执行 `apply` 应用所有迁移
|
||||
- **场景2 - 现有数据库**: 已有表结构 → 使用 `hash` 或 `set` 建立基线 → 继续正常迁移
|
||||
- **场景3 - 团队加入**: 新成员 → 克隆代码 → 创建本地数据库 → 执行 `apply`
|
||||
- **场景4 - 生产部署**: 已有生产数据 → 谨慎设置基线 → 应用新迁移
|
||||
|
||||
#### 1. 查看迁移状态
|
||||
```bash
|
||||
# 检查当前迁移状态
|
||||
go run cmd/migrate/main.go -action status
|
||||
|
||||
# 指定环境
|
||||
go run cmd/migrate/main.go -action status -env production
|
||||
```
|
||||
|
||||
#### 2. 生成迁移文件
|
||||
```bash
|
||||
# 基于当前模型生成迁移(自动命名)
|
||||
go run cmd/migrate/main.go -action diff
|
||||
|
||||
# 生成带自定义名称的迁移
|
||||
go run cmd/migrate/main.go -action diff -name create_users_table
|
||||
```
|
||||
|
||||
#### 3. 应用迁移
|
||||
```bash
|
||||
# 应用所有待执行的迁移
|
||||
go run cmd/migrate/main.go -action apply
|
||||
|
||||
# 模拟执行(显示将要执行的SQL,不实际执行)
|
||||
go run cmd/migrate/main.go -action apply -dry-run
|
||||
```
|
||||
|
||||
#### 4. 验证迁移
|
||||
```bash
|
||||
# 验证迁移文件的有效性
|
||||
go run cmd/migrate/main.go -action validate
|
||||
```
|
||||
|
||||
#### 5. 重置迁移历史(危险操作)
|
||||
```bash
|
||||
# 显示重置指导(不会直接执行)
|
||||
go run cmd/migrate/main.go -action reset
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
- **atlas.hcl**: Atlas 主配置文件,定义数据源和环境
|
||||
- **atlas_loader.go**: GORM 模型加载器,需要在此文件中注册所有数据模型
|
||||
- **migrations/**: 存储生成的迁移文件
|
||||
|
||||
### 环境配置
|
||||
|
||||
#### 本地开发环境 (local)
|
||||
修改 `atlas.hcl` 中的 `env "local"` 配置:
|
||||
```hcl
|
||||
env "local" {
|
||||
url = "postgres://username:password@localhost:5432/your_database?sslmode=disable" // 开发数据库
|
||||
dev = "postgres://username:password@localhost:5432/dev_database?sslmode=disable" // 计算差异数据库
|
||||
}
|
||||
```
|
||||
|
||||
#### 生产环境 (production)
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export DATABASE_URL="postgres://user:pass@host:port/db?sslmode=require"
|
||||
|
||||
# 应用迁移
|
||||
go run cmd/migrate/main.go -action apply -env production
|
||||
```
|
||||
|
||||
### 添加新模型
|
||||
|
||||
1. 在 `models/` 目录下创建新的模型文件
|
||||
2. 在 `atlas_loader.go` 中注册新模型:
|
||||
```go
|
||||
stmts, err := gormschema.New("postgres").Load(
|
||||
&models.User{},
|
||||
&models.NewModel{}, // 添加新模型
|
||||
)
|
||||
```
|
||||
3. 生成迁移:`go run cmd/migrate/main.go -action diff -name add_new_model`
|
||||
4. 应用迁移:`go run cmd/migrate/main.go -action apply`
|
||||
|
||||
### 注意事项
|
||||
|
||||
⚠️ **重要提醒**:
|
||||
|
||||
1. **备份数据库**: 在生产环境应用迁移前务必备份数据库
|
||||
2. **测试迁移**: 先在开发环境测试迁移的正确性
|
||||
3. **版本控制**: 迁移文件应纳入版本控制系统
|
||||
4. **环境隔离**: 不同环境使用不同的数据库连接
|
||||
5. **回滚策略**: Atlas 支持迁移回滚,但需要谨慎操作
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 如何回滚迁移?**
|
||||
```bash
|
||||
# 回滚到指定版本
|
||||
atlas migrate down --env local --to-version 20231201120000
|
||||
```
|
||||
|
||||
**Q: 如何重置迁移历史?**
|
||||
```bash
|
||||
# 删除迁移历史表(谨慎操作)
|
||||
atlas migrate reset --env local
|
||||
```
|
||||
|
||||
**Q: 生产环境迁移失败怎么办?**
|
||||
1. 检查迁移文件语法
|
||||
2. 确认数据库权限
|
||||
3. 查看详细错误日志
|
||||
4. 必要时手动修复数据库状态
|
||||
|
||||
---
|
||||
@@ -0,0 +1,48 @@
|
||||
data "external_schema" "gorm" {
|
||||
program = [
|
||||
"go",
|
||||
"run",
|
||||
"-mod=mod",
|
||||
"atlas_loader.go",
|
||||
]
|
||||
}
|
||||
|
||||
env "local" {
|
||||
src = data.external_schema.gorm.url
|
||||
dev = getenv("DATABASE_DEV_URL")
|
||||
url = getenv("DATABASE_URL")
|
||||
exclude = []
|
||||
migration {
|
||||
dir = "file://migrations"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
dev_url_clean = true
|
||||
}
|
||||
|
||||
env "production" {
|
||||
src = data.external_schema.gorm.url
|
||||
url = getenv("DATABASE_URL")
|
||||
exclude = []
|
||||
migration {
|
||||
dir = "file://migrations"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "DATABASE_URL" {
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "DATABASE_DEV_URL" {
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"ariga.io/atlas-provider-gorm/gormschema"
|
||||
|
||||
"skeleton/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
stmts, err := gormschema.New("postgres").Load(
|
||||
&models.User{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load gorm schema: %v", err)
|
||||
}
|
||||
io.WriteString(os.Stdout, stmts)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
env = flag.String("env", "local", "环境配置 (local/production)")
|
||||
action = flag.String("action", "", "操作类型: status, diff, apply, validate, reset")
|
||||
name = flag.String("name", "", "迁移名称 (仅用于 diff 操作)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *action == "" {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch *action {
|
||||
case "status":
|
||||
fmt.Printf("atlas migrate status --env %s\n", *env)
|
||||
case "diff":
|
||||
if *name != "" {
|
||||
fmt.Printf("atlas migrate diff %s --env %s\n", *name, *env)
|
||||
} else {
|
||||
fmt.Printf("atlas migrate diff --env %s\n", *env)
|
||||
}
|
||||
case "apply":
|
||||
fmt.Printf("atlas migrate apply --env %s\n", *env)
|
||||
case "validate":
|
||||
fmt.Printf("atlas migrate validate --env %s\n", *env)
|
||||
case "reset":
|
||||
fmt.Printf("atlas migrate reset --env %s\n", *env)
|
||||
default:
|
||||
fmt.Printf("未知操作: %s\n", *action)
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("数据库迁移管理工具")
|
||||
fmt.Println()
|
||||
fmt.Println("用法:")
|
||||
fmt.Println(" go run cmd/migrate/main.go -action <操作> [选项]")
|
||||
fmt.Println()
|
||||
fmt.Println("操作:")
|
||||
fmt.Println(" status 检查迁移状态")
|
||||
fmt.Println(" diff 生成迁移文件 (可选: -name <迁移名称>)")
|
||||
fmt.Println(" apply 应用迁移")
|
||||
fmt.Println(" validate 验证迁移文件")
|
||||
fmt.Println(" reset 重置迁移历史 (仅显示提示)")
|
||||
fmt.Println()
|
||||
fmt.Println("选项:")
|
||||
fmt.Println(" -env 环境配置 (默认: local)")
|
||||
fmt.Println(" -name 迁移名称 (仅用于 diff)")
|
||||
fmt.Println()
|
||||
fmt.Println("示例:")
|
||||
fmt.Println(" go run cmd/migrate/main.go -action status")
|
||||
fmt.Println(" go run cmd/migrate/main.go -action diff -name create_users")
|
||||
fmt.Println(" go run cmd/migrate/main.go -action apply")
|
||||
fmt.Println(" go run cmd/migrate/main.go -action validate")
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
app:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
version: "0.1.0"
|
||||
debug: true
|
||||
timezone: "UTC"
|
||||
environment: "development"
|
||||
|
||||
logger:
|
||||
level: "info"
|
||||
format: "console" # console, json
|
||||
output: "stdout" # stdout, file
|
||||
filename: "logs/app.log"
|
||||
max_size: 100
|
||||
max_age: 30
|
||||
max_backups: 3
|
||||
compress: false
|
||||
|
||||
database:
|
||||
host: "127.0.0.1"
|
||||
port: 2000
|
||||
username: "root"
|
||||
password: "123456"
|
||||
dbname: "skeleton"
|
||||
sslmode: "disable"
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
conn_max_lifetime: 60
|
||||
|
||||
redis:
|
||||
host: "127.0.0.1"
|
||||
port: 2001
|
||||
password: "123456"
|
||||
database: 0
|
||||
pool_size: 10
|
||||
min_idle_conns: 5
|
||||
max_retries: 3
|
||||
|
||||
jwt:
|
||||
secret: "change-this-secret-key-in-production"
|
||||
expire_hours: 24
|
||||
issuer: "HeTianXia"
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AppConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Version string `yaml:"version"`
|
||||
Debug bool `yaml:"debug"`
|
||||
Timezone string `yaml:"timezone"`
|
||||
Environment string `yaml:"environment"`
|
||||
}
|
||||
|
||||
type LoggerConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
Output string `yaml:"output"`
|
||||
Filename string `yaml:"filename"`
|
||||
MaxSize int `yaml:"max_size"`
|
||||
MaxAge int `yaml:"max_age"`
|
||||
MaxBackups int `yaml:"max_backups"`
|
||||
Compress bool `yaml:"compress"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Password string `yaml:"password"`
|
||||
Database int `yaml:"database"`
|
||||
PoolSize int `yaml:"pool_size"`
|
||||
MinIdleConns int `yaml:"min_idle_conns"`
|
||||
MaxRetries int `yaml:"max_retries"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
ExpireHours int `yaml:"expire_hours"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `yaml:"app"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
data, err := os.ReadFile("config/app.yaml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
cfg.setDefaults()
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.App.Host == "" {
|
||||
c.App.Host = "0.0.0.0"
|
||||
}
|
||||
if c.App.Port == 0 {
|
||||
c.App.Port = 8080
|
||||
}
|
||||
if c.App.Version == "" {
|
||||
c.App.Version = "0.1.0"
|
||||
}
|
||||
if c.App.Timezone == "" {
|
||||
c.App.Timezone = "UTC"
|
||||
}
|
||||
if c.App.Environment == "" {
|
||||
c.App.Environment = "development"
|
||||
}
|
||||
|
||||
if c.Logger.Level == "" {
|
||||
c.Logger.Level = "info"
|
||||
}
|
||||
if c.Logger.Format == "" {
|
||||
c.Logger.Format = "console"
|
||||
}
|
||||
if c.Logger.Output == "" {
|
||||
c.Logger.Output = "stdout"
|
||||
}
|
||||
if c.Logger.MaxSize == 0 {
|
||||
c.Logger.MaxSize = 100
|
||||
}
|
||||
if c.Logger.MaxAge == 0 {
|
||||
c.Logger.MaxAge = 30
|
||||
}
|
||||
if c.Logger.MaxBackups == 0 {
|
||||
c.Logger.MaxBackups = 3
|
||||
}
|
||||
|
||||
if c.Database.SSLMode == "" {
|
||||
c.Database.SSLMode = "disable"
|
||||
}
|
||||
if c.Database.MaxIdleConns == 0 {
|
||||
c.Database.MaxIdleConns = 10
|
||||
}
|
||||
if c.Database.MaxOpenConns == 0 {
|
||||
c.Database.MaxOpenConns = 100
|
||||
}
|
||||
if c.Database.ConnMaxLifetime == 0 {
|
||||
c.Database.ConnMaxLifetime = 60
|
||||
}
|
||||
|
||||
if c.Redis.Host == "" {
|
||||
c.Redis.Host = "localhost"
|
||||
}
|
||||
if c.Redis.Port == 0 {
|
||||
c.Redis.Port = 6379
|
||||
}
|
||||
if c.Redis.Database == 0 {
|
||||
c.Redis.Database = 0
|
||||
}
|
||||
if c.Redis.PoolSize == 0 {
|
||||
c.Redis.PoolSize = 10
|
||||
}
|
||||
if c.Redis.MinIdleConns == 0 {
|
||||
c.Redis.MinIdleConns = 5
|
||||
}
|
||||
if c.Redis.MaxRetries == 0 {
|
||||
c.Redis.MaxRetries = 3
|
||||
}
|
||||
|
||||
if c.JWT.Secret == "" {
|
||||
c.JWT.Secret = "change-this-secret-key-in-production"
|
||||
}
|
||||
if c.JWT.ExpireHours == 0 {
|
||||
c.JWT.ExpireHours = 24
|
||||
}
|
||||
if c.JWT.Issuer == "" {
|
||||
c.JWT.Issuer = "HeTianXia"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetAddr() string {
|
||||
return fmt.Sprintf("%s:%d", c.App.Host, c.App.Port)
|
||||
}
|
||||
|
||||
func (c *Config) GetDSN() string {
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
c.Database.Host,
|
||||
c.Database.Port,
|
||||
c.Database.Username,
|
||||
c.Database.Password,
|
||||
c.Database.DBName,
|
||||
c.Database.SSLMode,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 初始化数据库连接
|
||||
func Init(cfg *config.DatabaseConfig, log *zap.Logger) error {
|
||||
// 构建DSN
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.DBName, cfg.SSLMode)
|
||||
|
||||
// 配置GORM日志
|
||||
gormLogger := logger.New(
|
||||
&GormZapWriter{Logger: log},
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 打开数据库连接
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取底层的sql.DB以配置连接池
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取底层数据库连接失败: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
|
||||
|
||||
// 测试连接
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("数据库连接测试失败: %w", err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
|
||||
log.Info("数据库连接初始化成功",
|
||||
zap.String("host", cfg.Host),
|
||||
zap.Int("port", cfg.Port),
|
||||
zap.String("database", cfg.DBName),
|
||||
zap.Int("max_idle_conns", cfg.MaxIdleConns),
|
||||
zap.Int("max_open_conns", cfg.MaxOpenConns),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if DB != nil {
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// GormZapWriter GORM的Zap日志写入器
|
||||
type GormZapWriter struct {
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
func (g *GormZapWriter) Printf(format string, args ...interface{}) {
|
||||
g.Logger.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RedisClient Redis客户端封装
|
||||
type RedisClient struct {
|
||||
client *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
var redisClient *RedisClient
|
||||
|
||||
// InitRedis 初始化Redis客户端
|
||||
func InitRedis(cfg *config.Config, logger *zap.Logger) error {
|
||||
// 创建Redis客户端配置
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.Database,
|
||||
PoolSize: cfg.Redis.PoolSize,
|
||||
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
MaxRetries: cfg.Redis.MaxRetries,
|
||||
})
|
||||
|
||||
// 测试连接
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := rdb.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Redis连接失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建全局Redis客户端实例
|
||||
redisClient = &RedisClient{
|
||||
client: rdb,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
logger.Info("Redis客户端初始化成功",
|
||||
zap.String("host", cfg.Redis.Host),
|
||||
zap.Int("port", cfg.Redis.Port),
|
||||
zap.Int("database", cfg.Redis.Database),
|
||||
zap.Int("pool_size", cfg.Redis.PoolSize))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRedisClient 获取Redis客户端实例
|
||||
func GetRedisClient() *RedisClient {
|
||||
return redisClient
|
||||
}
|
||||
|
||||
// Close 关闭Redis连接
|
||||
func (r *RedisClient) Close() error {
|
||||
if r != nil && r.client != nil {
|
||||
return r.client.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
module skeleton
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/MUKE-coder/gin-docs v0.0.0-20260222113017-4d647cb4e7aa
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
go.uber.org/zap v1.27.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/strftime v1.1.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
github.com/MUKE-coder/gin-docs v0.0.0-20260222113017-4d647cb4e7aa h1:4V13TcfRa3Y3eM0PQ/vbuB4Af9xQFgcAXMGnVZmb8Kg=
|
||||
github.com/MUKE-coder/gin-docs v0.0.0-20260222113017-4d647cb4e7aa/go.mod h1:AZfgA2X2WWyAXvKox43IZdAi4fo+vvjtZnS0/NdHie0=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
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/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
|
||||
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
|
||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4=
|
||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible/go.mod h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA=
|
||||
github.com/lestrrat-go/strftime v1.1.1 h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYrUF0QU=
|
||||
github.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
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/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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
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/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
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.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"skeleton/config"
|
||||
"skeleton/database"
|
||||
"skeleton/middlewares"
|
||||
"skeleton/models"
|
||||
"skeleton/routes"
|
||||
_ "skeleton/routes/rest" // 导入触发 init() 自动注册路由
|
||||
"skeleton/utils"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/MUKE-coder/gin-docs/gindocs"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("加载配置失败:", err)
|
||||
}
|
||||
|
||||
// 初始化日志系统
|
||||
if err := middlewares.InitLogger(&cfg.Logger); err != nil {
|
||||
log.Fatal("初始化日志系统失败:", err)
|
||||
}
|
||||
|
||||
// 初始化JWT配置
|
||||
middlewares.InitJWT(cfg.JWT.Secret, cfg.JWT.ExpireHours, cfg.JWT.Issuer)
|
||||
|
||||
// 确保在程序退出时同步日志缓冲区
|
||||
defer middlewares.Sync()
|
||||
|
||||
// 使用结构化日志记录启动信息
|
||||
zap.L().Info("应用程序启动",
|
||||
zap.String("version", cfg.App.Version),
|
||||
zap.String("environment", cfg.App.Environment),
|
||||
zap.Bool("debug", cfg.App.Debug),
|
||||
)
|
||||
|
||||
// 初始化时区
|
||||
if err := utils.InitTimezone(cfg.App.Timezone); err != nil {
|
||||
zap.L().Warn("时区初始化警告", zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("时区设置成功", zap.String("timezone", cfg.App.Timezone))
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
if err := database.Init(&cfg.Database, zap.L()); err != nil {
|
||||
zap.L().Fatal("数据库初始化失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 初始化Redis服务
|
||||
if err := database.InitRedis(cfg, zap.L()); err != nil {
|
||||
zap.L().Fatal("Redis服务初始化失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 设置路由
|
||||
r := routes.SetupRoutes(cfg)
|
||||
|
||||
// 挂载接口文档
|
||||
gindocs.Mount(r, database.GetDB(), gindocs.Config{
|
||||
Title: "Skeleton API",
|
||||
Description: "Skeleton API documentation",
|
||||
Version: cfg.App.Version,
|
||||
Models: []interface{}{
|
||||
models.User{},
|
||||
},
|
||||
})
|
||||
|
||||
// 创建HTTP服务器
|
||||
srv := &http.Server{
|
||||
Addr: cfg.GetAddr(),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
go func() {
|
||||
zap.L().Info("HTTP 服务器启动",
|
||||
zap.String("address", cfg.GetAddr()),
|
||||
zap.String("version", cfg.App.Version),
|
||||
zap.String("environment", cfg.App.Environment),
|
||||
zap.Bool("debug", cfg.App.Debug),
|
||||
)
|
||||
|
||||
fmt.Printf("服务器启动在: %s (版本: %s, 环境: %s)\n",
|
||||
cfg.GetAddr(), cfg.App.Version, cfg.App.Environment)
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
zap.L().Fatal("启动服务器失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// 等待关闭信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
zap.L().Info("正在关闭服务器...")
|
||||
gracefulShutdown(srv)
|
||||
}
|
||||
|
||||
// gracefulShutdown 执行优雅关闭
|
||||
func gracefulShutdown(srv *http.Server) {
|
||||
// 设置关闭超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. 关闭HTTP服务器
|
||||
zap.L().Info("正在关闭HTTP服务器...")
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
zap.L().Error("HTTP服务器关闭失败", zap.Error(err))
|
||||
// 强制关闭
|
||||
if err := srv.Close(); err != nil {
|
||||
zap.L().Error("强制关闭HTTP服务器失败", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
zap.L().Info("HTTP服务器关闭成功")
|
||||
}
|
||||
|
||||
// 2. 关闭数据库连接
|
||||
if err := database.Close(); err != nil {
|
||||
zap.L().Error("关闭数据库连接失败", zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("数据库连接已关闭")
|
||||
}
|
||||
|
||||
// 3. 关闭Redis连接
|
||||
if redisClient := database.GetRedisClient(); redisClient != nil {
|
||||
if err := redisClient.Close(); err != nil {
|
||||
zap.L().Error("关闭Redis连接失败", zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("Redis连接已关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 同步日志缓冲区
|
||||
middlewares.Sync()
|
||||
|
||||
zap.L().Info("应用程序已优雅关闭")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.Request.Header.Get("Origin")
|
||||
|
||||
// 设置CORS头部
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
// 处理预检请求
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
jwtSecret []byte
|
||||
jwtIssuer string
|
||||
expireHours int
|
||||
)
|
||||
|
||||
// JWTClaims JWT声明结构
|
||||
type JWTClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// InitJWT 初始化JWT配置
|
||||
func InitJWT(secret string, expire int, issuer string) {
|
||||
jwtSecret = []byte(secret)
|
||||
expireHours = expire
|
||||
jwtIssuer = issuer
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
func GenerateToken(userID int, username string) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: jwtIssuer,
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// ParseToken 解析JWT token
|
||||
func ParseToken(tokenString string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, jwt.ErrTokenInvalidClaims
|
||||
}
|
||||
|
||||
// JWTAuth JWT认证中间件
|
||||
func JWTAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从Header获取token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
Logger.Warn("JWT认证失败:缺少Authorization头部",
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 401,
|
||||
"message": "缺少认证token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Bearer token格式检查
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
Logger.Warn("JWT认证失败:token格式错误",
|
||||
zap.String("auth_header", authHeader),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 401,
|
||||
"message": "token格式错误",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// 解析token
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil {
|
||||
Logger.Warn("JWT认证失败:token解析错误",
|
||||
zap.Error(err),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 401,
|
||||
"message": "token无效或已过期",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存储到上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
|
||||
Logger.Debug("JWT认证成功",
|
||||
zap.Int("user_id", claims.UserID),
|
||||
zap.String("username", claims.Username),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser 从上下文获取当前用户信息
|
||||
func GetCurrentUser(c *gin.Context) (int, string, bool) {
|
||||
userID, userExists := c.Get("user_id")
|
||||
username, nameExists := c.Get("username")
|
||||
|
||||
if !userExists || !nameExists {
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
return userID.(int), username.(string), true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
rotatelogs "github.com/lestrrat-go/file-rotatelogs"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var Logger *zap.Logger
|
||||
|
||||
// InitLogger 初始化日志系统
|
||||
func InitLogger(cfg *config.LoggerConfig) error {
|
||||
// 配置编码器
|
||||
var encoderConfig zapcore.EncoderConfig
|
||||
if cfg.Format == "json" {
|
||||
encoderConfig = zap.NewProductionEncoderConfig()
|
||||
} else {
|
||||
encoderConfig = zap.NewDevelopmentEncoderConfig()
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
}
|
||||
encoderConfig.TimeKey = "timestamp"
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
|
||||
// 选择编码器
|
||||
var encoder zapcore.Encoder
|
||||
if cfg.Format == "json" {
|
||||
encoder = zapcore.NewJSONEncoder(encoderConfig)
|
||||
} else {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderConfig)
|
||||
}
|
||||
|
||||
// 配置日志级别
|
||||
level := zap.InfoLevel
|
||||
switch strings.ToLower(cfg.Level) {
|
||||
case "debug":
|
||||
level = zap.DebugLevel
|
||||
case "info":
|
||||
level = zap.InfoLevel
|
||||
case "warn":
|
||||
level = zap.WarnLevel
|
||||
case "error":
|
||||
level = zap.ErrorLevel
|
||||
}
|
||||
|
||||
// 配置输出
|
||||
var writeSyncer zapcore.WriteSyncer
|
||||
if cfg.Output == "file" {
|
||||
// 创建日志目录
|
||||
logDir := filepath.Dir(cfg.Filename)
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 构建带日期的文件名模式
|
||||
ext := filepath.Ext(cfg.Filename)
|
||||
nameWithoutExt := strings.TrimSuffix(cfg.Filename, ext)
|
||||
|
||||
// 创建按日期轮转的日志写入器
|
||||
writer, err := rotatelogs.New(
|
||||
nameWithoutExt+".%Y%m%d"+ext,
|
||||
rotatelogs.WithLinkName(cfg.Filename),
|
||||
rotatelogs.WithRotationTime(24*time.Hour),
|
||||
rotatelogs.WithMaxAge(time.Duration(cfg.MaxAge)*24*time.Hour),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeSyncer = zapcore.AddSync(writer)
|
||||
} else {
|
||||
writeSyncer = zapcore.AddSync(os.Stdout)
|
||||
}
|
||||
|
||||
// 创建core
|
||||
core := zapcore.NewCore(encoder, writeSyncer, level)
|
||||
|
||||
// 创建logger
|
||||
Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
|
||||
|
||||
// 设置为全局logger,这样就可以使用 zap.L() 访问
|
||||
zap.ReplaceGlobals(Logger)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GinLogger 返回gin的日志中间件
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return gin.LoggerWithWriter(os.Stdout)
|
||||
}
|
||||
|
||||
// GinRecovery 返回gin的恢复中间件
|
||||
func GinRecovery() gin.HandlerFunc {
|
||||
return gin.RecoveryWithWriter(os.Stdout)
|
||||
}
|
||||
|
||||
// ErrorLogging 错误响应日志中间件
|
||||
func ErrorLogging() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
|
||||
// 记录错误状态码的响应
|
||||
if c.Writer.Status() >= 400 {
|
||||
Logger.Warn("HTTP错误响应",
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
zap.Int("status_code", c.Writer.Status()),
|
||||
zap.String("user_agent", c.GetHeader("User-Agent")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync 同步日志缓冲区
|
||||
func Sync() {
|
||||
if Logger != nil {
|
||||
Logger.Sync()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
// User 最小用户模型占位
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;not null;uniqueIndex" json:"username"`
|
||||
Password string `gorm:"size:255;not null" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 返回表名
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package example
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"skeleton/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExampleController 示例控制器
|
||||
type ExampleController struct {
|
||||
service *ExampleService
|
||||
}
|
||||
|
||||
// NewExampleController 创建示例控制器
|
||||
func NewExampleController() *ExampleController {
|
||||
return &ExampleController{
|
||||
service: NewExampleService(),
|
||||
}
|
||||
}
|
||||
|
||||
// Hello 示例接口
|
||||
func (c *ExampleController) Hello(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, utils.Success(c.service.Hello()))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package example
|
||||
|
||||
// ExampleService 示例服务
|
||||
type ExampleService struct{}
|
||||
|
||||
// NewExampleService 创建示例服务
|
||||
func NewExampleService() *ExampleService {
|
||||
return &ExampleService{}
|
||||
}
|
||||
|
||||
// Hello 返回示例消息
|
||||
func (s *ExampleService) Hello() HelloResponse {
|
||||
return HelloResponse{
|
||||
Message: "hello from skeleton example",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package example
|
||||
|
||||
// HelloResponse 示例响应
|
||||
type HelloResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package example
|
||||
|
||||
// normalizeMessage 示例模块私有工具
|
||||
func normalizeMessage(message string) string {
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"skeleton/modules/example"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(registerExampleRoutes)
|
||||
}
|
||||
|
||||
func registerExampleRoutes(r *gin.RouterGroup) {
|
||||
ctrl := example.NewExampleController()
|
||||
|
||||
group := r.Group("/example")
|
||||
group.GET("/hello", ctrl.Hello)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(registerHealthRoutes)
|
||||
}
|
||||
|
||||
func registerHealthRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/ping", func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "pong",
|
||||
"data": nil,
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/health", func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "ok",
|
||||
"data": nil,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 子路由注册函数签名
|
||||
type Registrar func(*gin.RouterGroup)
|
||||
|
||||
// 两类注册器:公开/私有(是否需要 JWT)
|
||||
var (
|
||||
publicRegistrars []Registrar
|
||||
privateRegistrars []Registrar
|
||||
)
|
||||
|
||||
// RegisterPublic 注册公开路由(无需认证)
|
||||
func RegisterPublic(fn Registrar) {
|
||||
publicRegistrars = append(publicRegistrars, fn)
|
||||
zap.L().Debug("公开路由注册器已添加",
|
||||
zap.Int("total_public", len(publicRegistrars)),
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterPrivate 注册私有路由(需要 JWT 认证)
|
||||
func RegisterPrivate(fn Registrar) {
|
||||
privateRegistrars = append(privateRegistrars, fn)
|
||||
zap.L().Debug("私有路由注册器已添加",
|
||||
zap.Int("total_private", len(privateRegistrars)),
|
||||
)
|
||||
}
|
||||
|
||||
// ApplyPublic 应用所有公开路由
|
||||
func ApplyPublic(group *gin.RouterGroup) {
|
||||
zap.L().Info("开始应用公开路由",
|
||||
zap.Int("count", len(publicRegistrars)),
|
||||
)
|
||||
|
||||
for i, fn := range publicRegistrars {
|
||||
fn(group)
|
||||
zap.L().Debug("公开路由已应用",
|
||||
zap.Int("index", i+1),
|
||||
)
|
||||
}
|
||||
|
||||
zap.L().Info("公开路由应用完成",
|
||||
zap.Int("applied_count", len(publicRegistrars)),
|
||||
)
|
||||
}
|
||||
|
||||
// ApplyPrivate 应用所有私有路由(带 JWT 中间件)
|
||||
func ApplyPrivate(group *gin.RouterGroup) {
|
||||
zap.L().Info("开始应用私有路由",
|
||||
zap.Int("count", len(privateRegistrars)),
|
||||
)
|
||||
|
||||
for i, fn := range privateRegistrars {
|
||||
fn(group)
|
||||
zap.L().Debug("私有路由已应用",
|
||||
zap.Int("index", i+1),
|
||||
)
|
||||
}
|
||||
|
||||
zap.L().Info("私有路由应用完成",
|
||||
zap.Int("applied_count", len(privateRegistrars)),
|
||||
)
|
||||
}
|
||||
|
||||
// GetRegistrarStats 获取注册器统计信息(调试用)
|
||||
func GetRegistrarStats() map[string]int {
|
||||
return map[string]int{
|
||||
"public": len(publicRegistrars),
|
||||
"private": len(privateRegistrars),
|
||||
"total": len(publicRegistrars) + len(privateRegistrars),
|
||||
}
|
||||
}
|
||||
|
||||
// PrintStats 打印注册器统计信息
|
||||
func PrintStats() {
|
||||
stats := GetRegistrarStats()
|
||||
zap.L().Info("路由注册器统计",
|
||||
zap.Int("public_count", stats["public"]),
|
||||
zap.Int("private_count", stats["private"]),
|
||||
zap.Int("total_count", stats["total"]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"skeleton/config"
|
||||
"skeleton/middlewares"
|
||||
"skeleton/routes/rest"
|
||||
"skeleton/routes/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SetupRoutes 设置主路由
|
||||
func SetupRoutes(cfg *config.Config) *gin.Engine {
|
||||
// 根据配置设置 Gin 模式
|
||||
if !cfg.App.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// 创建 Gin 路由器(不使用默认中间件)
|
||||
r := gin.New()
|
||||
|
||||
// 设置 multipart form 内存限制为 100MB
|
||||
r.MaxMultipartMemory = 100 << 20 // 100 MB
|
||||
|
||||
// 添加自定义中间件
|
||||
r.Use(middlewares.CORS()) // CORS跨域处理(需要在其他中间件之前)
|
||||
r.Use(middlewares.GinLogger()) // 结构化日志
|
||||
r.Use(middlewares.GinRecovery()) // 异常恢复
|
||||
r.Use(middlewares.ErrorLogging()) // 错误响应日志(用于记录逻辑异常)
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
|
||||
// 公开路由组(无需认证)
|
||||
public := api.Group("")
|
||||
rest.ApplyPublic(public)
|
||||
|
||||
// 私有路由组(需要 JWT 认证)
|
||||
private := api.Group("/private")
|
||||
private.Use(middlewares.JWTAuth()) // 启用JWT认证中间件
|
||||
rest.ApplyPrivate(private)
|
||||
|
||||
// WebSocket 路由组(无需认证)
|
||||
ws.ApplyWebSocketRoutes(api)
|
||||
|
||||
// 打印路由统计信息
|
||||
rest.PrintStats()
|
||||
|
||||
zap.L().Info("主路由设置完成",
|
||||
zap.Any("rest_stats", rest.GetRegistrarStats()),
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ws
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// ApplyWebSocketRoutes WebSocket路由挂载点
|
||||
func ApplyWebSocketRoutes(r *gin.RouterGroup) {
|
||||
_ = r
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaginationParams 分页参数
|
||||
type PaginationParams struct {
|
||||
Page int `form:"page" json:"page"` // 页码,从1开始
|
||||
PageSize int `form:"page_size" json:"page_size"` // 每页数量
|
||||
Search string `form:"search" json:"search"` // 搜索关键词
|
||||
}
|
||||
|
||||
// WelcomeQueryParams 欢迎语查询参数
|
||||
type WelcomeQueryParams struct {
|
||||
PaginationParams
|
||||
IsActive *bool `form:"is_active" json:"is_active"` // 启用状态筛选
|
||||
MessageType string `form:"message_type" json:"message_type"` // 消息类型筛选
|
||||
}
|
||||
|
||||
// CommonQueryParams 通用查询参数(关键词/快捷回复/群发)
|
||||
type CommonQueryParams struct {
|
||||
PaginationParams
|
||||
IsActive *bool `form:"is_active" json:"is_active"` // 启用状态筛选
|
||||
MessageType string `form:"message_type" json:"message_type"` // 消息类型筛选
|
||||
}
|
||||
|
||||
// PaginationResponse 分页响应
|
||||
type PaginationResponse struct {
|
||||
List interface{} `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
|
||||
// GetPaginationParams 从请求中获取分页参数
|
||||
func GetPaginationParams(ctx *gin.Context) PaginationParams {
|
||||
params := PaginationParams{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Search: "",
|
||||
}
|
||||
|
||||
if pageStr := ctx.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
|
||||
params.Page = page
|
||||
}
|
||||
}
|
||||
|
||||
if pageSizeStr := ctx.Query("page_size"); pageSizeStr != "" {
|
||||
if pageSize, err := strconv.Atoi(pageSizeStr); err == nil && pageSize > 0 && pageSize <= 100 {
|
||||
params.PageSize = pageSize
|
||||
}
|
||||
}
|
||||
|
||||
params.Search = ctx.Query("search")
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// GetWelcomeQueryParams 从请求中获取欢迎语查询参数
|
||||
func GetWelcomeQueryParams(ctx *gin.Context) WelcomeQueryParams {
|
||||
params := WelcomeQueryParams{
|
||||
PaginationParams: GetPaginationParams(ctx),
|
||||
MessageType: ctx.Query("message_type"),
|
||||
}
|
||||
|
||||
if isActiveStr := ctx.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
params.IsActive = &isActive
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// GetCommonQueryParams 从请求中获取通用查询参数
|
||||
func GetCommonQueryParams(ctx *gin.Context) CommonQueryParams {
|
||||
params := CommonQueryParams{
|
||||
PaginationParams: GetPaginationParams(ctx),
|
||||
MessageType: ctx.Query("message_type"),
|
||||
}
|
||||
|
||||
if isActiveStr := ctx.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
params.IsActive = &isActive
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ApplyPagination 应用分页到查询
|
||||
func ApplyPagination(db *gorm.DB, params PaginationParams) *gorm.DB {
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
return db.Offset(offset).Limit(params.PageSize)
|
||||
}
|
||||
|
||||
// CreatePaginationResponse 创建分页响应
|
||||
func CreatePaginationResponse(list interface{}, total int64, params PaginationParams) PaginationResponse {
|
||||
pages := int((total + int64(params.PageSize) - 1) / int64(params.PageSize))
|
||||
if pages == 0 {
|
||||
pages = 1
|
||||
}
|
||||
|
||||
return PaginationResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
Pages: pages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package utils
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// APIResponse 通用响应结构
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// Success 返回成功响应
|
||||
func Success(data interface{}) gin.H {
|
||||
return gin.H{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
|
||||
// Failure 返回失败响应
|
||||
func Failure(code int, message string) gin.H {
|
||||
return gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
"data": nil,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var localTimezone *time.Location
|
||||
|
||||
// InitTimezone 初始化时区设置
|
||||
func InitTimezone(timezone string) error {
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载时区失败 %s: %w", timezone, err)
|
||||
}
|
||||
|
||||
localTimezone = loc
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentTime 获取当前时间(使用设置的时区)
|
||||
func GetCurrentTime() time.Time {
|
||||
if localTimezone != nil {
|
||||
return time.Now().In(localTimezone)
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// GetCurrentTimeString 获取当前时间字符串
|
||||
func GetCurrentTimeString() string {
|
||||
return GetCurrentTime().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// GetCurrentDate 获取当前日期字符串
|
||||
func GetCurrentDate() string {
|
||||
return GetCurrentTime().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ParseTime 解析时间字符串到本地时区
|
||||
func ParseTime(layout, value string) (time.Time, error) {
|
||||
t, err := time.Parse(layout, value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
if localTimezone != nil {
|
||||
return t.In(localTimezone), nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// FormatTime 将时间格式化为字符串(使用本地时区)
|
||||
func FormatTime(t time.Time, layout string) string {
|
||||
if localTimezone != nil {
|
||||
return t.In(localTimezone).Format(layout)
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// GetTimezone 获取当前时区名称
|
||||
func GetTimezone() string {
|
||||
if localTimezone != nil {
|
||||
return localTimezone.String()
|
||||
}
|
||||
return time.Local.String()
|
||||
}
|
||||
Reference in New Issue
Block a user