This commit is contained in:
Hericium-sys
2026-04-25 13:26:02 +08:00
commit 62b87c6002
29 changed files with 1884 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 忽略项目根目录下的 .idea 文件夹
/.idea/
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+222
View File
@@ -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. 必要时手动修复数据库状态
---
+48
View File
@@ -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 = ""
}
+23
View File
@@ -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)
}
+66
View File
@@ -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")
}
+42
View File
@@ -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"
+173
View File
@@ -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,
)
}
+98
View File
@@ -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...))
}
+69
View File
@@ -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
}
+64
View File
@@ -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
)
+145
View File
@@ -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=
+149
View File
@@ -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("应用程序已优雅关闭")
}
+29
View File
@@ -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()
}
}
+142
View File
@@ -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
}
+127
View File
@@ -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()
}
}
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -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"
}
+26
View File
@@ -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()))
}
+16
View File
@@ -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",
}
}
+6
View File
@@ -0,0 +1,6 @@
package example
// HelloResponse 示例响应
type HelloResponse struct {
Message string `json:"message"`
}
+6
View File
@@ -0,0 +1,6 @@
package example
// normalizeMessage 示例模块私有工具
func normalizeMessage(message string) string {
return message
}
+18
View File
@@ -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)
}
+29
View File
@@ -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,
})
})
}
+86
View File
@@ -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"]),
)
}
+55
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
package ws
import "github.com/gin-gonic/gin"
// ApplyWebSocketRoutes WebSocket路由挂载点
func ApplyWebSocketRoutes(r *gin.RouterGroup) {
_ = r
}
+117
View File
@@ -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,
}
}
+28
View File
@@ -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,
}
}
+66
View File
@@ -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()
}