feat: 完善后端脚手架基础能力
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
cache: true
|
||||
- run: go test ./...
|
||||
- run: go vet ./...
|
||||
- run: ./scripts/build.sh
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: skeleton
|
||||
POSTGRES_PASSWORD: skeleton
|
||||
POSTGRES_DB: skeleton
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U skeleton"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 20
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: skeleton
|
||||
MYSQL_USER: skeleton
|
||||
MYSQL_PASSWORD: skeleton
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h localhost -proot"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 30
|
||||
env:
|
||||
TEST_POSTGRES_DSN: postgres://skeleton:skeleton@127.0.0.1:5432/skeleton?sslmode=disable
|
||||
TEST_MYSQL_DSN: skeleton:skeleton@tcp(127.0.0.1:3306)/skeleton?charset=utf8mb4&parseTime=True&loc=Local
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
cache: true
|
||||
- run: go test -tags=integration ./database
|
||||
@@ -10,3 +10,11 @@
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# 运行时与构建产物
|
||||
/data/
|
||||
/dist/
|
||||
/logs/
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache build-base
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/skeleton .
|
||||
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates tzdata && adduser -D -H -u 10001 app
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/skeleton /app/skeleton
|
||||
COPY config /app/config
|
||||
RUN mkdir -p /app/data && chown -R app:app /app
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/skeleton"]
|
||||
@@ -0,0 +1,33 @@
|
||||
.PHONY: test vet swagger build build-x64 build-native integration migrate-status migrate-diff migrate-apply
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
swagger:
|
||||
swag init
|
||||
|
||||
build:
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 go build -trimpath -o dist/skeleton .
|
||||
|
||||
build-x64:
|
||||
./scripts/build.sh
|
||||
|
||||
build-native:
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 go build -trimpath -o dist/skeleton-native .
|
||||
|
||||
integration:
|
||||
go test -tags=integration ./database
|
||||
|
||||
migrate-status:
|
||||
go run cmd/migrate/main.go -action status
|
||||
|
||||
migrate-diff:
|
||||
go run cmd/migrate/main.go -action diff -name $(name)
|
||||
|
||||
migrate-apply:
|
||||
go run cmd/migrate/main.go -action apply
|
||||
@@ -1,218 +1,497 @@
|
||||
# Skeleton
|
||||
# Go Skeleton
|
||||
|
||||
一个基于 Go 的后端脚手架项目,内置 Gin、GORM、Redis、JWT、Atlas 迁移管理和 `gin-docs` 自动接口文档,适合作为新项目起点。
|
||||
面向实际项目开发的 Go Web API 脚手架。它将 HTTP、WebSocket、认证、数据库迁移、缓存、接口文档和可观测性组织成可继续扩展的基础工程。
|
||||
|
||||
> 当前模块名为 `skeleton`。创建新项目后,请先使用初始化脚本替换成自己的 Go module path。
|
||||
|
||||
## 特性
|
||||
|
||||
- Gin HTTP 框架
|
||||
- 模块化路由组织
|
||||
- 统一响应结构
|
||||
- JWT 认证中间件
|
||||
- CORS、日志、恢复、错误记录中间件
|
||||
- PostgreSQL 和 Redis 初始化封装
|
||||
- Atlas + GORM Schema 迁移管理
|
||||
- `gin-docs` 自动扫描接口并生成 OpenAPI 文档
|
||||
- 预留 WebSocket 挂载点
|
||||
- Gin HTTP 路由及公开、JWT 私有路由分组
|
||||
- PostgreSQL、MySQL、SQLite 三种 GORM 数据库驱动
|
||||
- 按 SQL 方言隔离的 Atlas 数据库迁移
|
||||
- 可选 Redis,以及 JWT 撤销记录存储
|
||||
- Access Token、Refresh Token、刷新轮换和注销示例
|
||||
- WebSocket 房间、广播、连接管理和心跳示例
|
||||
- Swaggo OpenAPI 文档和 Swagger UI
|
||||
- Zap 结构化日志、请求 ID、Prometheus 指标
|
||||
- OpenTelemetry OTLP HTTP 链路追踪
|
||||
- Dockerfile、Docker Compose、GitHub Actions
|
||||
- Linux、macOS、Windows amd64 交叉编译
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速开始](#快速开始)
|
||||
- [项目结构](#项目结构)
|
||||
- [配置](#配置)
|
||||
- [HTTP API](#http-api)
|
||||
- [WebSocket](#websocket)
|
||||
- [数据库迁移](#数据库迁移)
|
||||
- [接口文档](#接口文档)
|
||||
- [可观测性](#可观测性)
|
||||
- [测试](#测试)
|
||||
- [编译](#编译)
|
||||
- [Docker](#docker)
|
||||
- [开发新模块](#开发新模块)
|
||||
- [安全说明](#安全说明)
|
||||
- [贡献](#贡献)
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 能力 | 实现 |
|
||||
| --- | --- |
|
||||
| HTTP | Gin |
|
||||
| WebSocket | Gorilla WebSocket |
|
||||
| ORM | GORM |
|
||||
| 数据库 | PostgreSQL / MySQL / SQLite |
|
||||
| 数据迁移 | Atlas + Atlas GORM Provider |
|
||||
| 缓存 | Redis,可选 |
|
||||
| 认证 | JWT HS256 + bcrypt |
|
||||
| API 文档 | Swaggo / Swagger UI |
|
||||
| 日志 | Zap |
|
||||
| 指标 | Prometheus |
|
||||
| Trace | OpenTelemetry OTLP HTTP |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
### 环境要求
|
||||
|
||||
- Go 1.25+
|
||||
- Atlas CLI,仅执行数据库迁移时需要
|
||||
- Swag CLI,仅重新生成 OpenAPI 文档时需要
|
||||
- Docker,可选
|
||||
- C 编译器,使用 SQLite 时需要
|
||||
|
||||
### 1. 创建自己的项目
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
git clone https://gitea.xchoumc.online/xchou/go-skeleton.git
|
||||
cd go-skeleton
|
||||
./scripts/init.sh github.com/yourname/your-project
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
### 2. 使用 SQLite 快速运行
|
||||
|
||||
修改 [`config/app.yaml`](config/app.yaml):
|
||||
修改 `config/app.yaml`:
|
||||
|
||||
- `app`:主机、端口、版本、环境、时区
|
||||
- `logger`:日志级别与输出方式
|
||||
- `database`:PostgreSQL 连接信息
|
||||
- `redis`:Redis 连接信息
|
||||
- `jwt`:Token 配置
|
||||
```yaml
|
||||
database:
|
||||
driver: sqlite
|
||||
sqlite_path: data/skeleton.db
|
||||
|
||||
### 3. 启动项目
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
默认监听地址由配置决定,示例为 `0.0.0.0:8080`。
|
||||
|
||||
## 接口文档
|
||||
|
||||
项目在启动时通过 `gin-docs` 自动挂载接口文档,无需手写 Swagger 注释。
|
||||
|
||||
默认文档地址:
|
||||
|
||||
- `/docs`
|
||||
|
||||
常见输出:
|
||||
|
||||
- `/docs/openapi.json`
|
||||
- `/docs/openapi.yaml`
|
||||
- `/docs/export/postman`
|
||||
- `/docs/export/insomnia`
|
||||
|
||||
文档会根据 Gin 路由和 GORM 模型自动推断生成。
|
||||
|
||||
## 路由结构
|
||||
|
||||
所有 API 默认挂载在 `/api` 下。
|
||||
|
||||
### 公开接口
|
||||
|
||||
- `GET /api/ping`
|
||||
- `GET /api/health`
|
||||
- `GET /api/example/hello`
|
||||
|
||||
### 私有接口
|
||||
|
||||
- `/api/private/*`
|
||||
|
||||
该分组默认挂载 JWT 认证中间件,适合后续需要登录态的业务接口。
|
||||
|
||||
### WebSocket
|
||||
|
||||
- `routes/ws/` 预留 WebSocket 扩展入口
|
||||
|
||||
## 模板初始化
|
||||
|
||||
如果这是通过 GitHub Template 生成的新仓库,可以直接运行初始化脚本,把 `go.mod` 和源码里的包路径一次性改掉:
|
||||
|
||||
```bash
|
||||
./scripts/init.sh github.com/yourname/yourrepo
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 更新 `go.mod` 的 module 名称
|
||||
- 替换源码中的旧包路径
|
||||
- 执行 `go mod tidy`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── main.go
|
||||
├── config/
|
||||
├── database/
|
||||
├── middlewares/
|
||||
├── models/
|
||||
├── modules/
|
||||
├── routes/
|
||||
├── utils/
|
||||
├── cmd/migrate/
|
||||
├── atlas.hcl
|
||||
├── atlas_loader.go
|
||||
└── config/app.yaml
|
||||
```
|
||||
|
||||
## 主要目录说明
|
||||
|
||||
- [`main.go`](main.go):启动入口,负责初始化配置、日志、JWT、数据库、Redis、路由和文档
|
||||
- [`routes/`](routes):REST 和 WebSocket 路由挂载
|
||||
- [`middlewares/`](middlewares):日志、CORS、JWT、恢复处理
|
||||
- [`database/`](database):PostgreSQL 和 Redis 连接封装
|
||||
- [`modules/`](modules):业务模块示例
|
||||
- [`models/`](models):GORM 模型
|
||||
- [`utils/`](utils):响应、分页、时区等通用工具
|
||||
- [`cmd/migrate/`](cmd/migrate/main.go):迁移命令输出入口
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
项目集成了 Atlas + GORM Provider,用于根据模型生成迁移。
|
||||
|
||||
### 相关文件
|
||||
|
||||
- [`atlas.hcl`](atlas.hcl)
|
||||
- [`atlas_loader.go`](atlas_loader.go)
|
||||
|
||||
### 安装 Atlas
|
||||
|
||||
```bash
|
||||
brew install ariga/tap/atlas
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```bash
|
||||
go install ariga.io/atlas/cmd/atlas@latest
|
||||
```
|
||||
|
||||
### 迁移命令
|
||||
|
||||
`cmd/migrate` 会根据参数输出对应的 Atlas 命令。
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
go run cmd/migrate/main.go -action status
|
||||
```
|
||||
|
||||
生成迁移:
|
||||
|
||||
```bash
|
||||
go run cmd/migrate/main.go -action diff
|
||||
go run cmd/migrate/main.go -action diff -name create_users
|
||||
redis:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
应用迁移:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL='sqlite://data/skeleton.db'
|
||||
export DATABASE_DEV_URL='sqlite://dev?mode=memory&_fk=1'
|
||||
go run cmd/migrate/main.go -action apply
|
||||
```
|
||||
|
||||
验证迁移:
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
go run cmd/migrate/main.go -action validate
|
||||
go run .
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
默认服务地址:
|
||||
|
||||
`atlas.hcl` 使用以下环境变量:
|
||||
| 服务 | 地址 |
|
||||
| --- | --- |
|
||||
| API | `http://localhost:8080/api` |
|
||||
| Swagger UI | `http://localhost:8080/docs/index.html` |
|
||||
| Prometheus Metrics | `http://localhost:8080/metrics` |
|
||||
| WebSocket | `ws://localhost:8080/api/ws` |
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `DATABASE_DEV_URL`
|
||||
## 项目结构
|
||||
|
||||
## 扩展新模块
|
||||
```text
|
||||
.
|
||||
├── main.go # 应用启动与优雅关闭
|
||||
├── config/ # YAML、环境变量及配置校验
|
||||
├── database/ # GORM、Redis、Token 撤销和迁移封装
|
||||
├── middlewares/ # JWT、日志、CORS、指标、Request ID
|
||||
├── models/ # GORM 数据模型
|
||||
├── modules/
|
||||
│ ├── auth/ # 注册、登录、刷新和注销示例
|
||||
│ ├── example/ # REST 业务模块示例
|
||||
│ └── ws/ # WebSocket Hub、Client 和消息协议
|
||||
├── observability/ # OpenTelemetry 初始化
|
||||
├── routes/
|
||||
│ ├── rest/ # REST 路由注册器
|
||||
│ └── ws/ # WebSocket 路由挂载
|
||||
├── docs/ # Swag 自动生成文件
|
||||
├── migrations/
|
||||
│ ├── postgres/
|
||||
│ ├── mysql/
|
||||
│ └── sqlite/
|
||||
├── tools/atlas-loader/ # 独立 Atlas GORM Schema 工具模块
|
||||
├── scripts/
|
||||
│ ├── init.sh # 修改 Go module path
|
||||
│ └── build.sh # 多平台 amd64 构建
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
新增业务时建议按以下方式组织:
|
||||
## 配置
|
||||
|
||||
1. 在 `modules/` 下创建新模块目录
|
||||
2. 在 `routes/rest/` 中注册路由
|
||||
3. 公共接口挂到公开路由组
|
||||
4. 需要登录态的接口挂到 `/api/private`
|
||||
5. 如有模型变更,先更新 `models/`,再生成迁移
|
||||
|
||||
## 示例模块
|
||||
|
||||
项目内置 `modules/example/` 作为参考实现,包含 controller、service 和 response 结构。
|
||||
|
||||
示例接口:
|
||||
应用默认读取 `config/app.yaml`。使用其他配置文件:
|
||||
|
||||
```bash
|
||||
GET /api/example/hello
|
||||
export SKELETON_CONFIG=config/app.yaml
|
||||
```
|
||||
|
||||
返回示例:
|
||||
环境变量优先级高于 YAML。生产环境应使用部署平台的 Secret 管理能力注入凭据。
|
||||
|
||||
### 应用和日志
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `APP_HOST`、`APP_PORT` | HTTP 监听地址 |
|
||||
| `APP_VERSION` | 应用版本 |
|
||||
| `APP_ENVIRONMENT` | `development` 或 `production` |
|
||||
| `APP_DEBUG` | Gin 调试模式 |
|
||||
| `APP_TIMEZONE` | IANA 时区名称 |
|
||||
| `LOG_LEVEL` | `debug`、`info`、`warn`、`error` |
|
||||
| `LOG_FORMAT` | `console` 或 `json` |
|
||||
| `LOG_OUTPUT` | `stdout` 或 `file` |
|
||||
|
||||
### 数据库
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `DATABASE_DRIVER` | `postgres`、`mysql` 或 `sqlite` |
|
||||
| `DATABASE_DSN` | 完整连接串,优先于其他连接参数 |
|
||||
| `DATABASE_HOST`、`DATABASE_PORT` | 服务端数据库地址 |
|
||||
| `DATABASE_USERNAME`、`DATABASE_PASSWORD` | 数据库凭据 |
|
||||
| `DATABASE_NAME` | 数据库名称 |
|
||||
| `DATABASE_SSLMODE` | PostgreSQL SSL 模式 |
|
||||
| `DATABASE_SQLITE_PATH` | SQLite 文件路径 |
|
||||
| `DATABASE_MAX_IDLE_CONNS` | 最大空闲连接数 |
|
||||
| `DATABASE_MAX_OPEN_CONNS` | 最大连接数 |
|
||||
| `DATABASE_CONN_MAX_LIFETIME` | 连接最大存活分钟数 |
|
||||
|
||||
未设置 `database.driver` 时默认使用 PostgreSQL。SQLite 会创建数据文件父目录,并默认限制为单连接。
|
||||
|
||||
### Redis、JWT 和可观测性
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `REDIS_ENABLED` | 是否启用 Redis |
|
||||
| `REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD` | Redis 连接信息 |
|
||||
| `JWT_SECRET` | HS256 签名密钥 |
|
||||
| `JWT_ACCESS_EXPIRE_MINUTES` | Access Token 有效分钟数 |
|
||||
| `JWT_REFRESH_EXPIRE_HOURS` | Refresh Token 有效小时数 |
|
||||
| `METRICS_ENABLED`、`METRICS_PATH` | Prometheus 指标开关和路径 |
|
||||
| `TRACING_ENABLED` | 是否启用 OpenTelemetry |
|
||||
| `OTEL_SERVICE_NAME` | Trace 服务名 |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP 地址,如 `localhost:4318` |
|
||||
| `OTEL_EXPORTER_OTLP_INSECURE` | 是否使用明文 OTLP |
|
||||
|
||||
生产模式会拒绝默认或少于 32 个字符的 JWT 密钥。
|
||||
|
||||
## HTTP API
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/ping` | 否 | 进程存活检查 |
|
||||
| `GET` | `/api/health` | 否 | 数据库和可选 Redis readiness 检查 |
|
||||
| `GET` | `/api/example/hello` | 否 | REST 模块示例 |
|
||||
| `POST` | `/api/auth/register` | 否 | 注册示例用户 |
|
||||
| `POST` | `/api/auth/login` | 否 | 获取 Token Pair |
|
||||
| `POST` | `/api/auth/refresh` | 否 | 轮换 Refresh Token |
|
||||
| `POST` | `/api/private/auth/logout` | Bearer | 撤销 Access/Refresh Token |
|
||||
| `GET` | `/api/ws` | 否 | WebSocket 握手入口 |
|
||||
|
||||
完整请求和响应模型请查看 Swagger UI。
|
||||
|
||||
### 认证示例
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"demo","password":"change-me-123"}'
|
||||
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"demo","password":"change-me-123"}'
|
||||
```
|
||||
|
||||
启用 Redis 时,Token 撤销记录存储在 Redis,适合多实例部署;关闭 Redis 时使用进程内存,仅适合开发或单实例部署。
|
||||
|
||||
## WebSocket
|
||||
|
||||
连接端点:
|
||||
|
||||
```text
|
||||
ws://localhost:8080/api/ws?room=lobby&client_id=demo
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `room` | 否 | 房间名,默认 `lobby`,最多 64 个字符 |
|
||||
| `client_id` | 否 | 客户端标识;未提供时服务端生成 |
|
||||
|
||||
使用 `websocat`:
|
||||
|
||||
```bash
|
||||
websocat 'ws://localhost:8080/api/ws?room=lobby&client_id=terminal-1'
|
||||
```
|
||||
|
||||
发送消息:
|
||||
|
||||
```json
|
||||
{"type":"message","data":"hello websocket"}
|
||||
```
|
||||
|
||||
服务端事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"message": "hello from skeleton example"
|
||||
}
|
||||
"type": "message",
|
||||
"room": "lobby",
|
||||
"client_id": "terminal-1",
|
||||
"data": "hello websocket",
|
||||
"timestamp": "2026-08-09T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 默认配置
|
||||
事件类型:
|
||||
|
||||
`config/app.yaml` 提供了可直接运行的本地示例配置。程序启动时会自动补充未填写的默认值,方便快速开发。
|
||||
| 类型 | 说明 |
|
||||
| --- | --- |
|
||||
| `welcome` | 当前客户端连接成功 |
|
||||
| `message` | 房间广播消息 |
|
||||
| `presence` | 客户端加入或离开 |
|
||||
| `error` | 消息格式或类型错误 |
|
||||
|
||||
## 说明
|
||||
浏览器示例:
|
||||
|
||||
这个脚手架的目标不是预置所有业务,而是把通用基础设施先搭好,方便后续按模块扩展。
|
||||
```javascript
|
||||
const socket = new WebSocket(
|
||||
"ws://localhost:8080/api/ws?room=lobby&client_id=browser-1"
|
||||
);
|
||||
|
||||
socket.onmessage = (event) => console.log(JSON.parse(event.data));
|
||||
socket.onopen = () => {
|
||||
socket.send(JSON.stringify({ type: "message", data: "hello" }));
|
||||
};
|
||||
```
|
||||
|
||||
WebSocket 实现包含:
|
||||
|
||||
- 按房间隔离广播
|
||||
- 在线连接数量
|
||||
- 单连接一个 reader 和一个 writer
|
||||
- Ping/Pong 心跳与读取超时
|
||||
- 8 KiB 单消息限制
|
||||
- 有界发送队列和慢客户端清理
|
||||
- 安全的默认同源 Origin 校验
|
||||
|
||||
当前 Hub 是单进程内存实现。多实例部署时,应在 `modules/ws/hub.go` 接入 Redis Pub/Sub、NATS 或其他消息系统。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
安装 Atlas:
|
||||
|
||||
```bash
|
||||
brew install ariga/tap/atlas
|
||||
# 或
|
||||
go install ariga.io/atlas/cmd/atlas@latest
|
||||
```
|
||||
|
||||
设置当前数据库和开发数据库 URL:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
export DATABASE_URL='postgres://user:pass@localhost:5432/app?sslmode=disable'
|
||||
export DATABASE_DEV_URL='docker://postgres/17/dev'
|
||||
|
||||
# MySQL
|
||||
export DATABASE_URL='mysql://user:pass@localhost:3306/app'
|
||||
export DATABASE_DEV_URL='docker://mysql/8/dev'
|
||||
|
||||
# SQLite
|
||||
export DATABASE_URL='sqlite://data/app.db'
|
||||
export DATABASE_DEV_URL='sqlite://dev?mode=memory&_fk=1'
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# 状态
|
||||
go run cmd/migrate/main.go -action status
|
||||
|
||||
# 根据 GORM 模型生成迁移
|
||||
go run cmd/migrate/main.go -action diff -name add_orders
|
||||
|
||||
# 校验
|
||||
go run cmd/migrate/main.go -action validate
|
||||
|
||||
# 预览
|
||||
go run cmd/migrate/main.go -action apply -dry-run
|
||||
|
||||
# 应用
|
||||
go run cmd/migrate/main.go -action apply
|
||||
|
||||
# 显式选择 Atlas 环境
|
||||
go run cmd/migrate/main.go -action status -env local_mysql
|
||||
```
|
||||
|
||||
迁移工具会根据 `app.environment` 和 `database.driver` 自动选择对应的 Atlas 环境。三种数据库的迁移文件不可混用。
|
||||
|
||||
## 接口文档
|
||||
|
||||
安装 Swag 并重新生成:
|
||||
|
||||
```bash
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.8.12
|
||||
swag init
|
||||
```
|
||||
|
||||
生成文件位于 `docs/`,应用运行时不依赖 Swag CLI。
|
||||
|
||||
## 可观测性
|
||||
|
||||
- `GET /api/ping`:liveness,不访问外部服务
|
||||
- `GET /api/health`:readiness,依赖异常时返回 HTTP 503
|
||||
- `GET /metrics`:Prometheus Counter 和 Histogram
|
||||
- `X-Request-ID`:接收上游 ID 或自动生成,并写入响应和日志
|
||||
- OpenTelemetry:启用后通过 OTLP HTTP 导出 Gin 请求 Span
|
||||
|
||||
Docker Compose 默认提供 Jaeger UI:`http://localhost:16686`。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试和静态检查:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./config ./database ./middlewares ./modules/ws
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
PostgreSQL 和 MySQL 集成测试:
|
||||
|
||||
```bash
|
||||
export TEST_POSTGRES_DSN='postgres://skeleton:skeleton@127.0.0.1:5432/skeleton?sslmode=disable'
|
||||
export TEST_MYSQL_DSN='skeleton:skeleton@tcp(127.0.0.1:3306)/skeleton?charset=utf8mb4&parseTime=True&loc=Local'
|
||||
go test -tags=integration ./database
|
||||
```
|
||||
|
||||
Makefile 快捷命令:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make vet
|
||||
make integration
|
||||
```
|
||||
|
||||
## 编译
|
||||
|
||||
当前平台无 CGO 构建:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Linux、macOS、Windows x64/amd64 交叉编译:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/skeleton-linux-amd64 .
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o dist/skeleton-darwin-amd64 .
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/skeleton-windows-amd64.exe .
|
||||
```
|
||||
|
||||
项目封装命令:
|
||||
|
||||
```bash
|
||||
VERSION=0.1.0 make build-x64
|
||||
```
|
||||
|
||||
SQLite 官方驱动依赖 CGO。跨平台 `CGO_ENABLED=0` 产物支持 PostgreSQL 和 MySQL;SQLite 应在目标平台原生构建:
|
||||
|
||||
```bash
|
||||
make build-native
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
默认启动应用、PostgreSQL、Redis、Atlas 迁移和 Jaeger:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
额外启动 MySQL:
|
||||
|
||||
```bash
|
||||
docker compose --profile mysql up -d mysql
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
删除本地 Compose 数据卷:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## 开发新模块
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
modules/order/
|
||||
├── controller.go
|
||||
├── service.go
|
||||
├── types.go
|
||||
└── repository.go
|
||||
```
|
||||
|
||||
开发流程:
|
||||
|
||||
1. 在 `modules/` 添加业务代码。
|
||||
2. 在 `routes/rest/` 注册公开或 JWT 私有路由。
|
||||
3. 在 `models/` 添加或修改 GORM 模型。
|
||||
4. 为目标数据库分别生成和检查 Atlas 迁移。
|
||||
5. 添加单元测试和必要的集成测试。
|
||||
6. 更新 Swag 注解并执行 `swag init`。
|
||||
|
||||
## 安全说明
|
||||
|
||||
该仓库是脚手架,不应在未经审查的情况下直接用于生产:
|
||||
|
||||
- 必须修改默认 JWT 密钥和示例数据库密码。
|
||||
- 根据实际前端域名收紧 CORS 和 WebSocket Origin 策略。
|
||||
- 示例注册接口应增加邀请码、管理员权限或在生产环境关闭。
|
||||
- 多实例 Token 撤销必须启用 Redis。
|
||||
- 多实例 WebSocket 广播必须接入外部消息系统。
|
||||
- TLS 应由网关、Ingress 或应用部署环境终止。
|
||||
|
||||
请通过私有渠道报告安全问题,不要在公开 Issue 中提交密钥或可利用细节。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。提交前请至少执行:
|
||||
|
||||
```bash
|
||||
go fmt ./...
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
建议每个 Pull Request 聚焦一个主题,并同步更新测试、迁移和文档。
|
||||
|
||||
## License
|
||||
|
||||
当前仓库尚未包含许可证文件。正式公开分发前,请由项目维护者选择并添加合适的开源许可证。
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
data "external_schema" "gorm" {
|
||||
program = [
|
||||
"go",
|
||||
"run",
|
||||
"-mod=mod",
|
||||
"atlas_loader.go",
|
||||
]
|
||||
data "external_schema" "gorm_postgres" {
|
||||
program = ["go", "-C", "tools/atlas-loader", "run", ".", "--dialect", "postgres"]
|
||||
}
|
||||
|
||||
env "local" {
|
||||
src = data.external_schema.gorm.url
|
||||
data "external_schema" "gorm_mysql" {
|
||||
program = ["go", "-C", "tools/atlas-loader", "run", ".", "--dialect", "mysql"]
|
||||
}
|
||||
|
||||
data "external_schema" "gorm_sqlite" {
|
||||
program = ["go", "-C", "tools/atlas-loader", "run", ".", "--dialect", "sqlite"]
|
||||
}
|
||||
|
||||
env "local_postgres" {
|
||||
src = data.external_schema.gorm_postgres.url
|
||||
dev = getenv("DATABASE_DEV_URL")
|
||||
url = getenv("DATABASE_URL")
|
||||
exclude = []
|
||||
migration {
|
||||
dir = "file://migrations"
|
||||
dir = "file://migrations/postgres"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
@@ -23,12 +25,11 @@ env "local" {
|
||||
dev_url_clean = true
|
||||
}
|
||||
|
||||
env "production" {
|
||||
src = data.external_schema.gorm.url
|
||||
env "production_postgres" {
|
||||
src = data.external_schema.gorm_postgres.url
|
||||
url = getenv("DATABASE_URL")
|
||||
exclude = []
|
||||
migration {
|
||||
dir = "file://migrations"
|
||||
dir = "file://migrations/postgres"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
@@ -37,12 +38,58 @@ env "production" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "DATABASE_URL" {
|
||||
type = string
|
||||
default = ""
|
||||
env "local_mysql" {
|
||||
src = data.external_schema.gorm_mysql.url
|
||||
dev = getenv("DATABASE_DEV_URL")
|
||||
url = getenv("DATABASE_URL")
|
||||
migration {
|
||||
dir = "file://migrations/mysql"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
dev_url_clean = true
|
||||
}
|
||||
|
||||
variable "DATABASE_DEV_URL" {
|
||||
type = string
|
||||
default = ""
|
||||
env "production_mysql" {
|
||||
src = data.external_schema.gorm_mysql.url
|
||||
url = getenv("DATABASE_URL")
|
||||
migration {
|
||||
dir = "file://migrations/mysql"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env "local_sqlite" {
|
||||
src = data.external_schema.gorm_sqlite.url
|
||||
dev = getenv("DATABASE_DEV_URL")
|
||||
url = getenv("DATABASE_URL")
|
||||
migration {
|
||||
dir = "file://migrations/sqlite"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
dev_url_clean = true
|
||||
}
|
||||
|
||||
env "production_sqlite" {
|
||||
src = data.external_schema.gorm_sqlite.url
|
||||
url = getenv("DATABASE_URL")
|
||||
migration {
|
||||
dir = "file://migrations/sqlite"
|
||||
}
|
||||
format {
|
||||
migrate {
|
||||
diff = "{{ sql . \" \" }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -15,7 +17,7 @@ import (
|
||||
|
||||
func main() {
|
||||
var (
|
||||
env = flag.String("env", "local", "环境配置 (local/production)")
|
||||
env = flag.String("env", "", "Atlas 环境,默认根据应用环境和数据库驱动推断")
|
||||
action = flag.String("action", "", "操作类型: status, diff, apply, validate, reset, baseline")
|
||||
name = flag.String("name", "", "迁移名称 (仅用于 diff 操作)")
|
||||
dryRun = flag.Bool("dry-run", false, "模拟执行,不实际应用迁移 (仅用于 apply 操作)")
|
||||
@@ -41,8 +43,17 @@ func main() {
|
||||
}
|
||||
defer middlewares.Sync()
|
||||
|
||||
driver := normalizeDriver(cfg.Database.Driver)
|
||||
if driver != "postgres" && driver != "mysql" && driver != "sqlite" {
|
||||
middlewares.Logger.Fatal("不支持的数据库驱动", zap.String("driver", cfg.Database.Driver))
|
||||
}
|
||||
if *env == "" {
|
||||
*env = atlasEnvironment(cfg.App.Environment, driver)
|
||||
}
|
||||
|
||||
migrationConfig := &database.MigrationConfig{
|
||||
Environment: *env,
|
||||
Directory: filepath.Join("migrations", driver),
|
||||
Timeout: 240,
|
||||
}
|
||||
|
||||
@@ -72,6 +83,25 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDriver(driver string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "postgresql":
|
||||
return "postgres"
|
||||
case "sqlite3":
|
||||
return "sqlite"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(driver))
|
||||
}
|
||||
}
|
||||
|
||||
func atlasEnvironment(environment, driver string) string {
|
||||
prefix := "local"
|
||||
if strings.EqualFold(environment, "production") {
|
||||
prefix = "production"
|
||||
}
|
||||
return prefix + "_" + driver
|
||||
}
|
||||
|
||||
func handleStatus(manager *database.MigrationManager) {
|
||||
fmt.Println("🔍 检查迁移状态...")
|
||||
if err := manager.CheckMigrations(); err != nil {
|
||||
@@ -96,12 +126,11 @@ func handleDiff(manager *database.MigrationManager, name string) {
|
||||
func handleApply(manager *database.MigrationManager, dryRun bool) {
|
||||
if dryRun {
|
||||
fmt.Println("🧪 模拟应用迁移 (dry-run)...")
|
||||
fmt.Println("注意: dry-run 功能需要在 Atlas 命令中添加 --dry-run 参数")
|
||||
} else {
|
||||
fmt.Println("🚀 应用迁移...")
|
||||
}
|
||||
|
||||
if err := manager.ApplyMigrations(); err != nil {
|
||||
if err := manager.ApplyMigrations(dryRun); err != nil {
|
||||
middlewares.Logger.Fatal("应用迁移失败", zap.Error(err))
|
||||
}
|
||||
fmt.Println("✅ 迁移应用完成")
|
||||
@@ -165,7 +194,7 @@ func printUsage() {
|
||||
fmt.Println(" baseline 基线迁移指导 (显示基线创建命令)")
|
||||
fmt.Println()
|
||||
fmt.Println("选项:")
|
||||
fmt.Println(" -env 环境配置 (默认: local)")
|
||||
fmt.Println(" -env Atlas 环境 (默认: local_<driver> 或 production_<driver>)")
|
||||
fmt.Println(" -name 迁移名称 (仅用于 diff)")
|
||||
fmt.Println(" -dry-run 模拟执行 (仅用于 apply)")
|
||||
fmt.Println()
|
||||
|
||||
+17
-3
@@ -17,17 +17,21 @@ logger:
|
||||
compress: false
|
||||
|
||||
database:
|
||||
driver: "postgres" # postgres, mysql, sqlite
|
||||
dsn: "" # 可选;填写后覆盖下方自动拼接的连接参数
|
||||
host: "127.0.0.1"
|
||||
port: 2000
|
||||
port: 0 # 0 表示按 driver 使用默认端口
|
||||
username: "root"
|
||||
password: "123456"
|
||||
dbname: "skeleton"
|
||||
sslmode: "disable"
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
sqlite_path: "data/skeleton.db"
|
||||
max_idle_conns: 0 # 0 表示使用 driver 默认值
|
||||
max_open_conns: 0 # SQLite 默认 1,服务端数据库默认 100
|
||||
conn_max_lifetime: 60
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
host: "127.0.0.1"
|
||||
port: 2001
|
||||
password: "123456"
|
||||
@@ -39,4 +43,14 @@ redis:
|
||||
jwt:
|
||||
secret: "change-this-secret-key-in-production"
|
||||
expire_hours: 24
|
||||
access_expire_minutes: 15
|
||||
refresh_expire_hours: 168
|
||||
issuer: "HeTianXia"
|
||||
|
||||
observability:
|
||||
metrics_enabled: true
|
||||
metrics_path: "/metrics"
|
||||
tracing_enabled: false
|
||||
tracing_service: "skeleton"
|
||||
tracing_endpoint: "localhost:4318"
|
||||
tracing_insecure: true
|
||||
|
||||
+166
-22
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -28,18 +30,22 @@ type LoggerConfig struct {
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string `yaml:"driver"`
|
||||
DSN string `yaml:"dsn"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
SQLitePath string `yaml:"sqlite_path"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Password string `yaml:"password"`
|
||||
@@ -50,21 +56,41 @@ type RedisConfig struct {
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
ExpireHours int `yaml:"expire_hours"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
Secret string `yaml:"secret"`
|
||||
ExpireHours int `yaml:"expire_hours"` // 兼容旧配置,作为 access token 默认值
|
||||
AccessExpireMinutes int `yaml:"access_expire_minutes"`
|
||||
RefreshExpireHours int `yaml:"refresh_expire_hours"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
}
|
||||
|
||||
type ObservabilityConfig struct {
|
||||
MetricsEnabled bool `yaml:"metrics_enabled"`
|
||||
MetricsPath string `yaml:"metrics_path"`
|
||||
TracingEnabled bool `yaml:"tracing_enabled"`
|
||||
TracingService string `yaml:"tracing_service"`
|
||||
TracingEndpoint string `yaml:"tracing_endpoint"`
|
||||
TracingInsecure bool `yaml:"tracing_insecure"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `yaml:"app"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
App AppConfig `yaml:"app"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Redis RedisConfig `yaml:"redis"`
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
Observability ObservabilityConfig `yaml:"observability"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
data, err := os.ReadFile("config/app.yaml")
|
||||
path := os.Getenv("SKELETON_CONFIG")
|
||||
if path == "" {
|
||||
path = "config/app.yaml"
|
||||
}
|
||||
return LoadFrom(path)
|
||||
}
|
||||
|
||||
func LoadFrom(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||
}
|
||||
@@ -74,10 +100,94 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyEnvironment()
|
||||
cfg.setDefaults()
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
switch c.Database.Driver {
|
||||
case "postgres", "mysql", "sqlite":
|
||||
default:
|
||||
return fmt.Errorf("不支持的数据库驱动 %q", c.Database.Driver)
|
||||
}
|
||||
if strings.EqualFold(c.App.Environment, "production") &&
|
||||
(c.JWT.Secret == "change-this-secret-key-in-production" || len(c.JWT.Secret) < 32) {
|
||||
return fmt.Errorf("生产环境 JWT_SECRET 必须修改且至少 32 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) applyEnvironment() {
|
||||
stringEnv("APP_HOST", &c.App.Host)
|
||||
intEnv("APP_PORT", &c.App.Port)
|
||||
stringEnv("APP_VERSION", &c.App.Version)
|
||||
boolEnv("APP_DEBUG", &c.App.Debug)
|
||||
stringEnv("APP_TIMEZONE", &c.App.Timezone)
|
||||
stringEnv("APP_ENVIRONMENT", &c.App.Environment)
|
||||
|
||||
stringEnv("LOG_LEVEL", &c.Logger.Level)
|
||||
stringEnv("LOG_FORMAT", &c.Logger.Format)
|
||||
stringEnv("LOG_OUTPUT", &c.Logger.Output)
|
||||
stringEnv("LOG_FILENAME", &c.Logger.Filename)
|
||||
|
||||
stringEnv("DATABASE_DRIVER", &c.Database.Driver)
|
||||
stringEnv("DATABASE_DSN", &c.Database.DSN)
|
||||
stringEnv("DATABASE_HOST", &c.Database.Host)
|
||||
intEnv("DATABASE_PORT", &c.Database.Port)
|
||||
stringEnv("DATABASE_USERNAME", &c.Database.Username)
|
||||
stringEnv("DATABASE_PASSWORD", &c.Database.Password)
|
||||
stringEnv("DATABASE_NAME", &c.Database.DBName)
|
||||
stringEnv("DATABASE_SSLMODE", &c.Database.SSLMode)
|
||||
stringEnv("DATABASE_SQLITE_PATH", &c.Database.SQLitePath)
|
||||
intEnv("DATABASE_MAX_IDLE_CONNS", &c.Database.MaxIdleConns)
|
||||
intEnv("DATABASE_MAX_OPEN_CONNS", &c.Database.MaxOpenConns)
|
||||
intEnv("DATABASE_CONN_MAX_LIFETIME", &c.Database.ConnMaxLifetime)
|
||||
|
||||
boolEnv("REDIS_ENABLED", &c.Redis.Enabled)
|
||||
stringEnv("REDIS_HOST", &c.Redis.Host)
|
||||
intEnv("REDIS_PORT", &c.Redis.Port)
|
||||
stringEnv("REDIS_PASSWORD", &c.Redis.Password)
|
||||
intEnv("REDIS_DATABASE", &c.Redis.Database)
|
||||
|
||||
stringEnv("JWT_SECRET", &c.JWT.Secret)
|
||||
intEnv("JWT_ACCESS_EXPIRE_MINUTES", &c.JWT.AccessExpireMinutes)
|
||||
intEnv("JWT_REFRESH_EXPIRE_HOURS", &c.JWT.RefreshExpireHours)
|
||||
stringEnv("JWT_ISSUER", &c.JWT.Issuer)
|
||||
|
||||
boolEnv("METRICS_ENABLED", &c.Observability.MetricsEnabled)
|
||||
stringEnv("METRICS_PATH", &c.Observability.MetricsPath)
|
||||
boolEnv("TRACING_ENABLED", &c.Observability.TracingEnabled)
|
||||
stringEnv("OTEL_SERVICE_NAME", &c.Observability.TracingService)
|
||||
stringEnv("OTEL_EXPORTER_OTLP_ENDPOINT", &c.Observability.TracingEndpoint)
|
||||
boolEnv("OTEL_EXPORTER_OTLP_INSECURE", &c.Observability.TracingInsecure)
|
||||
}
|
||||
|
||||
func stringEnv(key string, target *string) {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
func intEnv(key string, target *int) {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boolEnv(key string, target *bool) {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if parsed, err := strconv.ParseBool(value); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.App.Host == "" {
|
||||
c.App.Host = "0.0.0.0"
|
||||
@@ -114,14 +224,43 @@ func (c *Config) setDefaults() {
|
||||
c.Logger.MaxBackups = 3
|
||||
}
|
||||
|
||||
if c.Database.Driver == "" {
|
||||
c.Database.Driver = "postgres"
|
||||
}
|
||||
c.Database.Driver = strings.ToLower(strings.TrimSpace(c.Database.Driver))
|
||||
switch c.Database.Driver {
|
||||
case "postgresql":
|
||||
c.Database.Driver = "postgres"
|
||||
case "sqlite3":
|
||||
c.Database.Driver = "sqlite"
|
||||
}
|
||||
if c.Database.Port == 0 {
|
||||
switch c.Database.Driver {
|
||||
case "postgres":
|
||||
c.Database.Port = 5432
|
||||
case "mysql":
|
||||
c.Database.Port = 3306
|
||||
}
|
||||
}
|
||||
if c.Database.SSLMode == "" {
|
||||
c.Database.SSLMode = "disable"
|
||||
}
|
||||
if c.Database.SQLitePath == "" {
|
||||
c.Database.SQLitePath = "data/skeleton.db"
|
||||
}
|
||||
if c.Database.MaxIdleConns == 0 {
|
||||
c.Database.MaxIdleConns = 10
|
||||
if c.Database.Driver == "sqlite" {
|
||||
c.Database.MaxIdleConns = 1
|
||||
} else {
|
||||
c.Database.MaxIdleConns = 10
|
||||
}
|
||||
}
|
||||
if c.Database.MaxOpenConns == 0 {
|
||||
c.Database.MaxOpenConns = 100
|
||||
if c.Database.Driver == "sqlite" {
|
||||
c.Database.MaxOpenConns = 1
|
||||
} else {
|
||||
c.Database.MaxOpenConns = 100
|
||||
}
|
||||
}
|
||||
if c.Database.ConnMaxLifetime == 0 {
|
||||
c.Database.ConnMaxLifetime = 60
|
||||
@@ -152,22 +291,27 @@ func (c *Config) setDefaults() {
|
||||
if c.JWT.ExpireHours == 0 {
|
||||
c.JWT.ExpireHours = 24
|
||||
}
|
||||
if c.JWT.AccessExpireMinutes == 0 {
|
||||
c.JWT.AccessExpireMinutes = c.JWT.ExpireHours * 60
|
||||
}
|
||||
if c.JWT.RefreshExpireHours == 0 {
|
||||
c.JWT.RefreshExpireHours = 24 * 7
|
||||
}
|
||||
if c.JWT.Issuer == "" {
|
||||
c.JWT.Issuer = "HeTianXia"
|
||||
}
|
||||
|
||||
if c.Observability.MetricsPath == "" {
|
||||
c.Observability.MetricsPath = "/metrics"
|
||||
}
|
||||
if c.Observability.TracingService == "" {
|
||||
c.Observability.TracingService = "skeleton"
|
||||
}
|
||||
if c.Observability.TracingEndpoint == "" {
|
||||
c.Observability.TracingEndpoint = "localhost:4318"
|
||||
}
|
||||
}
|
||||
|
||||
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,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvironmentOverridesYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app.yaml")
|
||||
if err := os.WriteFile(path, []byte("database:\n driver: postgres\nredis:\n enabled: false\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DATABASE_DRIVER", "mysql")
|
||||
t.Setenv("DATABASE_PORT", "3307")
|
||||
t.Setenv("REDIS_ENABLED", "true")
|
||||
t.Setenv("JWT_ACCESS_EXPIRE_MINUTES", "30")
|
||||
|
||||
cfg, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom() error = %v", err)
|
||||
}
|
||||
if cfg.Database.Driver != "mysql" || cfg.Database.Port != 3307 {
|
||||
t.Fatalf("database override failed: %+v", cfg.Database)
|
||||
}
|
||||
if !cfg.Redis.Enabled || cfg.JWT.AccessExpireMinutes != 30 {
|
||||
t.Fatalf("environment override failed: redis=%v jwt=%+v", cfg.Redis.Enabled, cfg.JWT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app.yaml")
|
||||
if err := os.WriteFile(path, []byte("database:\n driver: sqlite\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.SQLitePath == "" || cfg.Database.MaxOpenConns != 1 {
|
||||
t.Fatalf("unexpected SQLite defaults: %+v", cfg.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionRejectsDefaultJWTSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "app.yaml")
|
||||
if err := os.WriteFile(path, []byte("app:\n environment: production\ndatabase:\n driver: sqlite\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadFrom(path); err == nil {
|
||||
t.Fatal("production config accepted the default JWT secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// Init 根据配置的 driver 初始化数据库连接。
|
||||
func Init(cfg *config.DatabaseConfig, log *zap.Logger) error {
|
||||
if err := prepareSQLiteDirectory(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dialector, err := Dialector(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gormLogger := logger.New(
|
||||
&GormZapWriter{Logger: log},
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Info,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{Logger: gormLogger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接 %s 数据库失败: %w", cfg.Driver, err)
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = sqlDB.Close()
|
||||
return fmt.Errorf("%s 数据库连接测试失败: %w", cfg.Driver, err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
log.Info("数据库连接初始化成功",
|
||||
zap.String("driver", cfg.Driver),
|
||||
zap.String("database", databaseName(cfg)),
|
||||
zap.Int("max_idle_conns", cfg.MaxIdleConns),
|
||||
zap.Int("max_open_conns", cfg.MaxOpenConns),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareSQLiteDirectory(cfg *config.DatabaseConfig) error {
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
if driver != "sqlite" && driver != "sqlite3" {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := BuildDSN(cfg)
|
||||
if path == "" || path == ":memory:" || strings.HasPrefix(path, "file:") {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dialector 创建对应数据库的 GORM 方言实例,便于应用和测试复用。
|
||||
func Dialector(cfg *config.DatabaseConfig) (gorm.Dialector, error) {
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
dsn := BuildDSN(cfg)
|
||||
|
||||
switch driver {
|
||||
case "postgres", "postgresql":
|
||||
return postgres.Open(dsn), nil
|
||||
case "mysql":
|
||||
return mysql.Open(dsn), nil
|
||||
case "sqlite", "sqlite3":
|
||||
return sqliteDialector(dsn)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的数据库驱动 %q,可选值: postgres, mysql, sqlite", cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDSN 返回显式 DSN,或根据结构化配置构建对应方言的连接串。
|
||||
func BuildDSN(cfg *config.DatabaseConfig) string {
|
||||
if cfg.DSN != "" {
|
||||
return cfg.DSN
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.Driver)) {
|
||||
case "mysql":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
cfg.Username, cfg.Password, cfg.Host, cfg.Port, cfg.DBName)
|
||||
case "sqlite", "sqlite3":
|
||||
return cfg.SQLitePath
|
||||
default:
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
|
||||
func databaseName(cfg *config.DatabaseConfig) string {
|
||||
if strings.HasPrefix(strings.ToLower(cfg.Driver), "sqlite") {
|
||||
return BuildDSN(cfg)
|
||||
}
|
||||
return cfg.DBName
|
||||
}
|
||||
|
||||
func Close() error {
|
||||
if DB == nil {
|
||||
return nil
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
// Ping 检查关系数据库连接是否可用。
|
||||
func Ping(ctx context.Context) error {
|
||||
if DB == nil {
|
||||
return fmt.Errorf("数据库尚未初始化")
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.PingContext(ctx)
|
||||
}
|
||||
|
||||
type GormZapWriter struct {
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
func (g *GormZapWriter) Printf(format string, args ...interface{}) {
|
||||
g.Logger.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"skeleton/config"
|
||||
)
|
||||
|
||||
func TestBuildDSN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.DatabaseConfig
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "explicit DSN wins",
|
||||
cfg: config.DatabaseConfig{Driver: "mysql", DSN: "custom-dsn"},
|
||||
want: "custom-dsn",
|
||||
},
|
||||
{
|
||||
name: "postgres",
|
||||
cfg: config.DatabaseConfig{Driver: "postgres", Host: "db", Port: 5432,
|
||||
Username: "app", Password: "secret", DBName: "demo", SSLMode: "require"},
|
||||
want: "host=db port=5432 user=app password=secret dbname=demo sslmode=require",
|
||||
},
|
||||
{
|
||||
name: "mysql",
|
||||
cfg: config.DatabaseConfig{Driver: "mysql", Host: "db", Port: 3306,
|
||||
Username: "app", Password: "secret", DBName: "demo"},
|
||||
want: "app:secret@tcp(db:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
},
|
||||
{
|
||||
name: "sqlite",
|
||||
cfg: config.DatabaseConfig{Driver: "sqlite", SQLitePath: "data/demo.db"},
|
||||
want: "data/demo.db",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := BuildDSN(&tt.cfg); got != tt.want {
|
||||
t.Fatalf("BuildDSN() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialectorRejectsUnknownDriver(t *testing.T) {
|
||||
_, err := Dialector(&config.DatabaseConfig{Driver: "oracle"})
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持的数据库驱动") {
|
||||
t.Fatalf("Dialector() error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build integration
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"skeleton/config"
|
||||
"skeleton/models"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestPostgresAndMySQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
driver string
|
||||
env string
|
||||
}{
|
||||
{driver: "postgres", env: "TEST_POSTGRES_DSN"},
|
||||
{driver: "mysql", env: "TEST_MYSQL_DSN"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.driver, func(t *testing.T) {
|
||||
dsn := os.Getenv(tt.env)
|
||||
if dsn == "" {
|
||||
t.Skipf("%s 未设置", tt.env)
|
||||
}
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: tt.driver, DSN: dsn,
|
||||
MaxIdleConns: 1, MaxOpenConns: 2, ConnMaxLifetime: 1,
|
||||
}
|
||||
if err := Init(&cfg, zap.NewNop()); err != nil {
|
||||
t.Fatalf("Init() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close(); DB = nil })
|
||||
if err := DB.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
user := models.User{
|
||||
Username: fmt.Sprintf("integration_%s_%d", tt.driver, time.Now().UnixNano()),
|
||||
Password: "test-hash",
|
||||
}
|
||||
if err := DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if err := DB.Delete(&user).Error; err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
// MigrationConfig 迁移配置
|
||||
type MigrationConfig struct {
|
||||
Environment string // local, production
|
||||
Environment string // 例如 local_postgres、production_mysql
|
||||
Directory string // 按数据库方言隔离的迁移目录
|
||||
Timeout int // 超时时间(秒)
|
||||
}
|
||||
|
||||
@@ -77,11 +78,15 @@ func (m *MigrationManager) GenerateMigration(name string) error {
|
||||
}
|
||||
|
||||
// ApplyMigrations 应用迁移
|
||||
func (m *MigrationManager) ApplyMigrations() error {
|
||||
func (m *MigrationManager) ApplyMigrations(dryRun bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(m.config.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "atlas", "migrate", "apply", "--env", m.config.Environment)
|
||||
args := []string{"migrate", "apply", "--env", m.config.Environment}
|
||||
if dryRun {
|
||||
args = append(args, "--dry-run")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "atlas", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
@@ -91,7 +96,9 @@ func (m *MigrationManager) ApplyMigrations() error {
|
||||
return fmt.Errorf("应用迁移失败: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("迁移应用成功", zap.String("output", string(output)))
|
||||
m.logger.Info("迁移应用成功",
|
||||
zap.Bool("dry_run", dryRun),
|
||||
zap.String("output", string(output)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +133,10 @@ func EnsureAtlasInstalled() error {
|
||||
|
||||
// InitMigrationDirectory 初始化迁移目录
|
||||
func (m *MigrationManager) InitMigrationDirectory() error {
|
||||
migrationDir := "migrations"
|
||||
migrationDir := m.config.Directory
|
||||
if migrationDir == "" {
|
||||
migrationDir = "migrations"
|
||||
}
|
||||
|
||||
// 检查目录是否存在
|
||||
if _, err := os.Stat(migrationDir); os.IsNotExist(err) {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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...))
|
||||
}
|
||||
@@ -21,6 +21,11 @@ var redisClient *RedisClient
|
||||
|
||||
// InitRedis 初始化Redis客户端
|
||||
func InitRedis(cfg *config.Config, logger *zap.Logger) error {
|
||||
if !cfg.Redis.Enabled {
|
||||
redisClient = nil
|
||||
logger.Info("Redis 已禁用")
|
||||
return nil
|
||||
}
|
||||
// 创建Redis客户端配置
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Redis.Host, cfg.Redis.Port),
|
||||
@@ -55,6 +60,18 @@ func InitRedis(cfg *config.Config, logger *zap.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping 检查 Redis 是否可用。
|
||||
func (r *RedisClient) Ping(ctx context.Context) error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func RedisEnabled() bool {
|
||||
return redisClient != nil
|
||||
}
|
||||
|
||||
// GetRedisClient 获取Redis客户端实例
|
||||
func GetRedisClient() *RedisClient {
|
||||
return redisClient
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build cgo
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func sqliteDialector(dsn string) (gorm.Dialector, error) {
|
||||
return sqlite.Open(dsn), nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !cgo
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func sqliteDialector(string) (gorm.Dialector, error) {
|
||||
return nil, fmt.Errorf("SQLite 驱动需要 CGO;请使用 CGO_ENABLED=1 在目标平台编译")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build cgo
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestInitSQLiteInMemory(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
MaxIdleConns: 1,
|
||||
MaxOpenConns: 1,
|
||||
}
|
||||
if err := Init(&cfg, zap.NewNop()); err != nil {
|
||||
t.Fatalf("Init() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = Close()
|
||||
DB = nil
|
||||
})
|
||||
if GetDB() == nil {
|
||||
t.Fatal("GetDB() returned nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var revokedTokens sync.Map
|
||||
|
||||
func RevokeToken(ctx context.Context, tokenID string, ttl time.Duration) error {
|
||||
if tokenID == "" || ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
if redisClient != nil {
|
||||
return redisClient.client.Set(ctx, "jwt:revoked:"+tokenID, "1", ttl).Err()
|
||||
}
|
||||
revokedTokens.Store(tokenID, time.Now().Add(ttl))
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsTokenRevoked(ctx context.Context, tokenID string) (bool, error) {
|
||||
if tokenID == "" {
|
||||
return false, fmt.Errorf("token 缺少 jti")
|
||||
}
|
||||
if redisClient != nil {
|
||||
count, err := redisClient.client.Exists(ctx, "jwt:revoked:"+tokenID).Result()
|
||||
return count > 0, err
|
||||
}
|
||||
value, ok := revokedTokens.Load(tokenID)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
expiresAt := value.(time.Time)
|
||||
if time.Now().After(expiresAt) {
|
||||
revokedTokens.Delete(tokenID)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DATABASE_DRIVER: postgres
|
||||
DATABASE_DSN: postgres://skeleton:skeleton@postgres:5432/skeleton?sslmode=disable
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
JWT_SECRET: local-compose-secret-change-me
|
||||
TRACING_ENABLED: "true"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: jaeger:4318
|
||||
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
migrate:
|
||||
image: arigaio/atlas:latest
|
||||
command: migrate apply --url postgres://skeleton:skeleton@postgres:5432/skeleton?sslmode=disable --dir file:///migrations
|
||||
volumes:
|
||||
- ./migrations/postgres:/migrations:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: skeleton
|
||||
POSTGRES_PASSWORD: skeleton
|
||||
POSTGRES_DB: skeleton
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U skeleton"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
profiles: ["mysql"]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: skeleton
|
||||
MYSQL_USER: skeleton
|
||||
MYSQL_PASSWORD: skeleton
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.68.0
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "4318:4318"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
mysql_data:
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
// Code generated by swaggo/swag. DO NOT EDIT.
|
||||
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"contact": {},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "用户登录",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "登录信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Credentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/middlewares.TokenPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/refresh": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "刷新并轮换 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Refresh Token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RefreshRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/middlewares.TokenPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "注册示例用户",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "注册信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Credentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/example/hello": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"example"
|
||||
],
|
||||
"summary": "返回示例消息",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/example.HelloResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "服务健康检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/rest.healthData"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/rest.healthData"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ping": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "服务连通性检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/private/auth/logout": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "注销并撤销 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "可选 Refresh Token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LogoutRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.Credentials": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"maxLength": 72,
|
||||
"minLength": 8,
|
||||
"example": "change-me-123"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"minLength": 3,
|
||||
"example": "demo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LogoutRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example.HelloResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"middlewares.TokenPair": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"example": 900
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_type": {
|
||||
"type": "string",
|
||||
"example": "Bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rest.healthData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"redis": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rest.systemResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"example": 200
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utils.APIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"description": "输入 Bearer {token}",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "0.1.0",
|
||||
Host: "",
|
||||
BasePath: "/api",
|
||||
Schemes: []string{},
|
||||
Title: "Skeleton API",
|
||||
Description: "Skeleton API documentation.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Skeleton API documentation.",
|
||||
"title": "Skeleton API",
|
||||
"contact": {},
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"basePath": "/api",
|
||||
"paths": {
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "用户登录",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "登录信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Credentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/middlewares.TokenPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/refresh": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "刷新并轮换 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Refresh Token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RefreshRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/middlewares.TokenPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "注册示例用户",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "注册信息",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Credentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/example/hello": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"example"
|
||||
],
|
||||
"summary": "返回示例消息",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/example.HelloResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "服务健康检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/rest.healthData"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/rest.healthData"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ping": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"system"
|
||||
],
|
||||
"summary": "服务连通性检查",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rest.systemResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/private/auth/logout": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "注销并撤销 Token",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "可选 Refresh Token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LogoutRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/utils.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.Credentials": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"maxLength": 72,
|
||||
"minLength": 8,
|
||||
"example": "change-me-123"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"minLength": 3,
|
||||
"example": "demo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LogoutRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example.HelloResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"middlewares.TokenPair": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"example": 900
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_type": {
|
||||
"type": "string",
|
||||
"example": "Bearer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rest.healthData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"redis": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rest.systemResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"example": 200
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utils.APIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"description": "输入 Bearer {token}",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
basePath: /api
|
||||
definitions:
|
||||
auth.Credentials:
|
||||
properties:
|
||||
password:
|
||||
example: change-me-123
|
||||
maxLength: 72
|
||||
minLength: 8
|
||||
type: string
|
||||
username:
|
||||
example: demo
|
||||
maxLength: 64
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
auth.LogoutRequest:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
type: object
|
||||
auth.RefreshRequest:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
required:
|
||||
- refresh_token
|
||||
type: object
|
||||
auth.UserResponse:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
example.HelloResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
middlewares.TokenPair:
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
expires_in:
|
||||
example: 900
|
||||
type: integer
|
||||
refresh_token:
|
||||
type: string
|
||||
token_type:
|
||||
example: Bearer
|
||||
type: string
|
||||
type: object
|
||||
rest.healthData:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
redis:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
rest.systemResponse:
|
||||
properties:
|
||||
code:
|
||||
example: 200
|
||||
type: integer
|
||||
data: {}
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
utils.APIResponse:
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
data: {}
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
description: Skeleton API documentation.
|
||||
title: Skeleton API
|
||||
version: 0.1.0
|
||||
paths:
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 登录信息
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.Credentials'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/utils.APIResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/middlewares.TokenPair'
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/utils.APIResponse'
|
||||
summary: 用户登录
|
||||
tags:
|
||||
- auth
|
||||
/auth/refresh:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Refresh Token
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.RefreshRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/utils.APIResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/middlewares.TokenPair'
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/utils.APIResponse'
|
||||
summary: 刷新并轮换 Token
|
||||
tags:
|
||||
- auth
|
||||
/auth/register:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 注册信息
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.Credentials'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/utils.APIResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/auth.UserResponse'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/utils.APIResponse'
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
$ref: '#/definitions/utils.APIResponse'
|
||||
summary: 注册示例用户
|
||||
tags:
|
||||
- auth
|
||||
/example/hello:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/utils.APIResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/example.HelloResponse'
|
||||
type: object
|
||||
summary: 返回示例消息
|
||||
tags:
|
||||
- example
|
||||
/health:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/rest.systemResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/rest.healthData'
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/rest.systemResponse'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/rest.healthData'
|
||||
type: object
|
||||
summary: 服务健康检查
|
||||
tags:
|
||||
- system
|
||||
/ping:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/rest.systemResponse'
|
||||
summary: 服务连通性检查
|
||||
tags:
|
||||
- system
|
||||
/private/auth/logout:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: 可选 Refresh Token
|
||||
in: body
|
||||
name: body
|
||||
schema:
|
||||
$ref: '#/definitions/auth.LogoutRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/utils.APIResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: 注销并撤销 Token
|
||||
tags:
|
||||
- auth
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: 输入 Bearer {token}
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
@@ -1,33 +1,60 @@
|
||||
module skeleton
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/MUKE-coder/gin-docs v0.0.0-20260222113017-4d647cb4e7aa
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.8.12
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite 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
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // 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/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-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/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.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
|
||||
@@ -35,30 +62,44 @@ require (
|
||||
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/josharian/intern v1.0.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/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // 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
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.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
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,46 +1,86 @@
|
||||
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=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse 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/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.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/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
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=
|
||||
@@ -55,14 +95,23 @@ 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
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/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
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=
|
||||
@@ -71,12 +120,22 @@ github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkL
|
||||
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/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.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/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
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=
|
||||
@@ -84,10 +143,18 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/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=
|
||||
@@ -95,51 +162,136 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.8.12 h1:pctzkNPu0AlQP2royqX3apjKCQonAnf7KGoxeO4y64w=
|
||||
github.com/swaggo/swag v1.8.12/go.mod h1:lNfm6Gg+oAq3zRJQNEMBE66LIJKM44mxFqhEEgy2its=
|
||||
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=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
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/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -9,24 +9,39 @@ import (
|
||||
"os/signal"
|
||||
"skeleton/config"
|
||||
"skeleton/database"
|
||||
"skeleton/docs"
|
||||
"skeleton/middlewares"
|
||||
"skeleton/models"
|
||||
"skeleton/observability"
|
||||
"skeleton/routes"
|
||||
_ "skeleton/routes/rest" // 导入触发 init() 自动注册路由
|
||||
"skeleton/utils"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/MUKE-coder/gin-docs/gindocs"
|
||||
"github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var buildVersion = "dev"
|
||||
|
||||
// @title Skeleton API
|
||||
// @version 0.1.0
|
||||
// @description Skeleton API documentation.
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description 输入 Bearer {token}
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("加载配置失败:", err)
|
||||
}
|
||||
if buildVersion != "dev" {
|
||||
cfg.App.Version = buildVersion
|
||||
}
|
||||
|
||||
// 初始化日志系统
|
||||
if err := middlewares.InitLogger(&cfg.Logger); err != nil {
|
||||
@@ -34,11 +49,23 @@ func main() {
|
||||
}
|
||||
|
||||
// 初始化JWT配置
|
||||
middlewares.InitJWT(cfg.JWT.Secret, cfg.JWT.ExpireHours, cfg.JWT.Issuer)
|
||||
middlewares.InitJWT(cfg.JWT.Secret, cfg.JWT.AccessExpireMinutes, cfg.JWT.RefreshExpireHours, cfg.JWT.Issuer)
|
||||
|
||||
// 确保在程序退出时同步日志缓冲区
|
||||
defer middlewares.Sync()
|
||||
|
||||
shutdownTracing, err := observability.InitTracing(context.Background(), cfg.Observability)
|
||||
if err != nil {
|
||||
zap.L().Fatal("链路追踪初始化失败", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := shutdownTracing(shutdownCtx); err != nil {
|
||||
zap.L().Error("链路追踪关闭失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
// 使用结构化日志记录启动信息
|
||||
zap.L().Info("应用程序启动",
|
||||
zap.String("version", cfg.App.Version),
|
||||
@@ -66,15 +93,9 @@ func main() {
|
||||
// 设置路由
|
||||
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{},
|
||||
},
|
||||
})
|
||||
// 挂载 Swagger UI,文档由 `swag init` 根据注解生成。
|
||||
docs.SwaggerInfo.Version = cfg.App.Version
|
||||
r.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 创建HTTP服务器
|
||||
srv := &http.Server{
|
||||
|
||||
+106
-90
@@ -1,142 +1,158 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"skeleton/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
jwtSecret []byte
|
||||
jwtIssuer string
|
||||
expireHours int
|
||||
const (
|
||||
TokenTypeAccess = "access"
|
||||
TokenTypeRefresh = "refresh"
|
||||
)
|
||||
|
||||
var (
|
||||
jwtSecret []byte
|
||||
jwtIssuer string
|
||||
accessExpire time.Duration
|
||||
refreshExpire time.Duration
|
||||
)
|
||||
|
||||
// JWTClaims JWT声明结构
|
||||
type JWTClaims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TokenType string `json:"token_type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// InitJWT 初始化JWT配置
|
||||
func InitJWT(secret string, expire int, issuer string) {
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type" example:"Bearer"`
|
||||
ExpiresIn int64 `json:"expires_in" example:"900"`
|
||||
}
|
||||
|
||||
func InitJWT(secret string, accessMinutes, refreshHours int, issuer string) {
|
||||
jwtSecret = []byte(secret)
|
||||
expireHours = expire
|
||||
accessExpire = time.Duration(accessMinutes) * time.Minute
|
||||
refreshExpire = time.Duration(refreshHours) * time.Hour
|
||||
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)
|
||||
return generateToken(userID, username, TokenTypeAccess, accessExpire)
|
||||
}
|
||||
|
||||
// ParseToken 解析JWT token
|
||||
func ParseToken(tokenString string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
func GenerateTokenPair(userID int, username string) (TokenPair, error) {
|
||||
access, err := generateToken(userID, username, TokenTypeAccess, accessExpire)
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
refresh, err := generateToken(userID, username, TokenTypeRefresh, refreshExpire)
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
return TokenPair{
|
||||
AccessToken: access, RefreshToken: refresh,
|
||||
TokenType: "Bearer", ExpiresIn: int64(accessExpire.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateToken(userID int, username, tokenType string, duration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := JWTClaims{
|
||||
UserID: userID, Username: username, TokenType: tokenType,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: randomTokenID(), ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), Issuer: jwtIssuer,
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func randomTokenID() string {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(*jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(jwtIssuer))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
claims, ok := token.Claims.(*JWTClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, jwt.ErrTokenInvalidClaims
|
||||
}
|
||||
|
||||
return nil, jwt.ErrTokenInvalidClaims
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
tokenString, err := BearerToken(c.GetHeader("Authorization"))
|
||||
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()
|
||||
abortUnauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
claims, err := ParseToken(tokenString)
|
||||
if err != nil || claims.TokenType != TokenTypeAccess {
|
||||
abortUnauthorized(c, "token 无效或已过期")
|
||||
return
|
||||
}
|
||||
revoked, err := database.IsTokenRevoked(c.Request.Context(), claims.ID)
|
||||
if err != nil || revoked {
|
||||
abortUnauthorized(c, "token 已撤销")
|
||||
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.Set("jwt_claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser 从上下文获取当前用户信息
|
||||
func BearerToken(header string) (string, error) {
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", fmt.Errorf("缺少或格式错误的认证 token")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
func abortUnauthorized(c *gin.Context, message string) {
|
||||
Logger.Warn("JWT认证失败", zap.String("message", message), zap.String("path", c.Request.URL.Path))
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": message, "data": nil})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func GetCurrentClaims(c *gin.Context) (*JWTClaims, bool) {
|
||||
value, ok := c.Get("jwt_claims")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := value.(*JWTClaims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package middlewares
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGenerateTokenPair(t *testing.T) {
|
||||
InitJWT("test-secret", 15, 24, "test-issuer")
|
||||
pair, err := GenerateTokenPair(42, "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := ParseToken(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refresh, err := ParseToken(pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.TokenType != TokenTypeAccess || refresh.TokenType != TokenTypeRefresh {
|
||||
t.Fatalf("unexpected token types: %s, %s", access.TokenType, refresh.TokenType)
|
||||
}
|
||||
if access.ID == "" || refresh.ID == "" || access.ID == refresh.ID {
|
||||
t.Fatal("tokens must have distinct IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerToken(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer abc"); err != nil || token != "abc" {
|
||||
t.Fatalf("BearerToken() = %q, %v", token, err)
|
||||
}
|
||||
if _, err := BearerToken("abc"); err == nil {
|
||||
t.Fatal("BearerToken() accepted malformed header")
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -93,7 +93,19 @@ func InitLogger(cfg *config.LoggerConfig) error {
|
||||
|
||||
// GinLogger 返回gin的日志中间件
|
||||
func GinLogger() gin.HandlerFunc {
|
||||
return gin.LoggerWithWriter(os.Stdout)
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
requestID, _ := c.Get("request_id")
|
||||
Logger.Info("HTTP请求",
|
||||
zap.String("request_id", stringValue(requestID)),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.Int("status_code", c.Writer.Status()),
|
||||
zap.Duration("latency", time.Since(started)),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GinRecovery 返回gin的恢复中间件
|
||||
@@ -108,7 +120,9 @@ func ErrorLogging() gin.HandlerFunc {
|
||||
|
||||
// 记录错误状态码的响应
|
||||
if c.Writer.Status() >= 400 {
|
||||
requestID, _ := c.Get("request_id")
|
||||
Logger.Warn("HTTP错误响应",
|
||||
zap.String("request_id", stringValue(requestID)),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", c.Request.URL.Path),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
@@ -119,6 +133,13 @@ func ErrorLogging() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sync 同步日志缓冲区
|
||||
func Sync() {
|
||||
if Logger != nil {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "skeleton_http_requests_total",
|
||||
Help: "Total number of HTTP requests.",
|
||||
}, []string{"method", "route", "status"})
|
||||
httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "skeleton_http_request_duration_seconds",
|
||||
Help: "HTTP request latency in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method", "route", "status"})
|
||||
)
|
||||
|
||||
func init() {
|
||||
prometheus.MustRegister(httpRequests, httpDuration)
|
||||
}
|
||||
|
||||
func Metrics() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
status := strconv.Itoa(c.Writer.Status())
|
||||
httpRequests.WithLabelValues(c.Request.Method, route, status).Inc()
|
||||
httpDuration.WithLabelValues(c.Request.Method, route, status).Observe(time.Since(started).Seconds())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.GetHeader(RequestIDHeader)
|
||||
if requestID == "" {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err == nil {
|
||||
requestID = hex.EncodeToString(value)
|
||||
}
|
||||
}
|
||||
c.Set("request_id", requestID)
|
||||
c.Header(RequestIDHeader, requestID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE `users` (
|
||||
`id` bigint unsigned AUTO_INCREMENT,
|
||||
`username` varchar(64) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `idx_users_username` (`username`)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
h1:AuV9gfTOCOAkvdNG+rTnCuRXCsNdGIw99ESIcigZToI=
|
||||
20260809000100_create_users.sql h1:HHCsnYCu6DPnudNHIL4lR6h50Ndlr9AHcwQxD0APinM=
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE "users" (
|
||||
"id" bigserial,
|
||||
"username" varchar(64) NOT NULL,
|
||||
"password" varchar(255) NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "idx_users_username" ON "users" ("username");
|
||||
@@ -0,0 +1,2 @@
|
||||
h1:KL8m6Hvz0rQhkIfZfIWtoDNwAe1u9V3jCr/y1WE4rfM=
|
||||
20260809000100_create_users.sql h1:sbELXVx7emjO8qCox06MIhKn70i/adn89MUIgDTLCto=
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`username` text NOT NULL,
|
||||
`password` text NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||
@@ -0,0 +1,2 @@
|
||||
h1:rMchBIYMumzVULbnGqmawAZKbkJM8S9Hjok6Qg4zagU=
|
||||
20260809000100_create_users.sql h1:JEyy3iqFHNJb4WSR8YNhbFqhxd+B3JIwqxpDWJF393o=
|
||||
@@ -0,0 +1,154 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"skeleton/database"
|
||||
"skeleton/middlewares"
|
||||
"skeleton/models"
|
||||
"skeleton/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Controller struct{}
|
||||
|
||||
func NewController() *Controller { return &Controller{} }
|
||||
|
||||
// Register godoc
|
||||
// @Summary 注册示例用户
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body Credentials true "注册信息"
|
||||
// @Success 201 {object} utils.APIResponse{data=UserResponse}
|
||||
// @Failure 400 {object} utils.APIResponse
|
||||
// @Failure 409 {object} utils.APIResponse
|
||||
// @Router /auth/register [post]
|
||||
func (c *Controller) Register(ctx *gin.Context) {
|
||||
var input Credentials
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, utils.Failure(400, err.Error()))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "密码处理失败"))
|
||||
return
|
||||
}
|
||||
user := models.User{Username: input.Username, Password: string(hash)}
|
||||
if err := database.GetDB().Create(&user).Error; err != nil {
|
||||
ctx.JSON(http.StatusConflict, utils.Failure(409, "用户名已存在或数据无效"))
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusCreated, utils.Success(UserResponse{ID: user.ID, Username: user.Username}))
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary 用户登录
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body Credentials true "登录信息"
|
||||
// @Success 200 {object} utils.APIResponse{data=middlewares.TokenPair}
|
||||
// @Failure 401 {object} utils.APIResponse
|
||||
// @Router /auth/login [post]
|
||||
func (c *Controller) Login(ctx *gin.Context) {
|
||||
var input Credentials
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, utils.Failure(400, err.Error()))
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
err := database.GetDB().Where("username = ?", input.Username).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
ctx.JSON(http.StatusUnauthorized, utils.Failure(401, "用户名或密码错误"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "查询用户失败"))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)) != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, utils.Failure(401, "用户名或密码错误"))
|
||||
return
|
||||
}
|
||||
pair, err := middlewares.GenerateTokenPair(int(user.ID), user.Username)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "生成 token 失败"))
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, utils.Success(pair))
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary 刷新并轮换 Token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body RefreshRequest true "Refresh Token"
|
||||
// @Success 200 {object} utils.APIResponse{data=middlewares.TokenPair}
|
||||
// @Failure 401 {object} utils.APIResponse
|
||||
// @Router /auth/refresh [post]
|
||||
func (c *Controller) Refresh(ctx *gin.Context) {
|
||||
var input RefreshRequest
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, utils.Failure(400, err.Error()))
|
||||
return
|
||||
}
|
||||
claims, err := middlewares.ParseToken(input.RefreshToken)
|
||||
if err != nil || claims.TokenType != middlewares.TokenTypeRefresh {
|
||||
ctx.JSON(http.StatusUnauthorized, utils.Failure(401, "refresh token 无效或已过期"))
|
||||
return
|
||||
}
|
||||
revoked, err := database.IsTokenRevoked(ctx.Request.Context(), claims.ID)
|
||||
if err != nil || revoked {
|
||||
ctx.JSON(http.StatusUnauthorized, utils.Failure(401, "refresh token 已撤销"))
|
||||
return
|
||||
}
|
||||
if err := revokeClaims(ctx, claims); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "撤销旧 token 失败"))
|
||||
return
|
||||
}
|
||||
pair, err := middlewares.GenerateTokenPair(claims.UserID, claims.Username)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "生成 token 失败"))
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, utils.Success(pair))
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @Summary 注销并撤销 Token
|
||||
// @Tags auth
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body LogoutRequest false "可选 Refresh Token"
|
||||
// @Success 200 {object} utils.APIResponse
|
||||
// @Router /private/auth/logout [post]
|
||||
func (c *Controller) Logout(ctx *gin.Context) {
|
||||
claims, _ := middlewares.GetCurrentClaims(ctx)
|
||||
if err := revokeClaims(ctx, claims); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, utils.Failure(500, "撤销 access token 失败"))
|
||||
return
|
||||
}
|
||||
var input LogoutRequest
|
||||
if ctx.ShouldBindJSON(&input) == nil && input.RefreshToken != "" {
|
||||
if refreshClaims, err := middlewares.ParseToken(input.RefreshToken); err == nil && refreshClaims.TokenType == middlewares.TokenTypeRefresh {
|
||||
_ = revokeClaims(ctx, refreshClaims)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusOK, utils.Success(nil))
|
||||
}
|
||||
|
||||
func revokeClaims(ctx *gin.Context, claims *middlewares.JWTClaims) error {
|
||||
if claims == nil || claims.ExpiresAt == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RevokeToken(ctx.Request.Context(), claims.ID, time.Until(claims.ExpiresAt.Time))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
type Credentials struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64" example:"demo"`
|
||||
Password string `json:"password" binding:"required,min=8,max=72" example:"change-me-123"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
@@ -20,7 +20,12 @@ func NewExampleController() *ExampleController {
|
||||
}
|
||||
}
|
||||
|
||||
// Hello 示例接口
|
||||
// Hello godoc
|
||||
// @Summary 返回示例消息
|
||||
// @Tags example
|
||||
// @Produce json
|
||||
// @Success 200 {object} utils.APIResponse{data=HelloResponse}
|
||||
// @Router /example/hello [get]
|
||||
func (c *ExampleController) Hello(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, utils.Success(c.service.Hello()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = pongWait * 9 / 10
|
||||
maxMessageSize = 8 * 1024
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan OutgoingMessage
|
||||
room string
|
||||
clientID string
|
||||
}
|
||||
|
||||
func NewClient(hub *Hub, conn *websocket.Conn, room, clientID string) *Client {
|
||||
return &Client{
|
||||
hub: hub, conn: conn, send: make(chan OutgoingMessage, 64),
|
||||
room: room, clientID: clientID,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ReadPump() {
|
||||
defer func() {
|
||||
remaining := c.hub.Unregister(c)
|
||||
_ = c.conn.Close()
|
||||
c.hub.Broadcast(c.room, presenceMessage(c.room, c.clientID, "left", remaining))
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
var incoming IncomingMessage
|
||||
if err := c.conn.ReadJSON(&incoming); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
zap.L().Warn("WebSocket读取失败", zap.String("client_id", c.clientID), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if incoming.Type != EventMessage || incoming.Data == "" {
|
||||
c.send <- OutgoingMessage{Type: EventError, Data: "仅支持非空 message 事件", Timestamp: time.Now()}
|
||||
continue
|
||||
}
|
||||
c.hub.Broadcast(c.room, OutgoingMessage{
|
||||
Type: EventMessage, Room: c.room, ClientID: c.clientID,
|
||||
Data: incoming.Data, Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) WritePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteJSON(message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presenceMessage(room, clientID, action string, online int) OutgoingMessage {
|
||||
return OutgoingMessage{
|
||||
Type: EventPresence, Room: room, ClientID: clientID,
|
||||
Data: action, Online: online, Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
hub *Hub
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewController(hub *Hub) *Controller {
|
||||
return &Controller{
|
||||
hub: hub,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 升级 HTTP 连接并加入指定房间。
|
||||
// 浏览器连接示例:new WebSocket("ws://localhost:8080/api/ws?room=lobby&client_id=demo")
|
||||
func (c *Controller) Connect(ctx *gin.Context) {
|
||||
room := strings.TrimSpace(ctx.DefaultQuery("room", "lobby"))
|
||||
clientID := strings.TrimSpace(ctx.Query("client_id"))
|
||||
if room == "" || len(room) > 64 || len(clientID) > 64 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "room 或 client_id 不合法", "data": nil})
|
||||
return
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = randomClientID()
|
||||
}
|
||||
|
||||
conn, err := c.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||
if err != nil {
|
||||
zap.L().Warn("WebSocket升级失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
client := NewClient(c.hub, conn, room, clientID)
|
||||
online := c.hub.Register(client)
|
||||
client.send <- OutgoingMessage{
|
||||
Type: EventWelcome, Room: room, ClientID: clientID,
|
||||
Data: "connected", Online: online, Timestamp: time.Now(),
|
||||
}
|
||||
c.hub.Broadcast(room, presenceMessage(room, clientID, "joined", online))
|
||||
|
||||
go client.WritePump()
|
||||
client.ReadPump()
|
||||
}
|
||||
|
||||
func randomClientID() string {
|
||||
value := make([]byte, 8)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "anonymous"
|
||||
}
|
||||
return hex.EncodeToString(value)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestControllerConnectAndBroadcast(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
hub := NewHub()
|
||||
controller := NewController(hub)
|
||||
router := gin.New()
|
||||
router.GET("/ws", controller.Connect)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
url := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?room=test&client_id=tester"
|
||||
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial() error = %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
|
||||
// 连接后依次收到 welcome 和 joined presence。
|
||||
for i := 0; i < 2; i++ {
|
||||
var message OutgoingMessage
|
||||
if err := conn.ReadJSON(&message); err != nil {
|
||||
t.Fatalf("initial ReadJSON() error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := conn.WriteJSON(IncomingMessage{Type: EventMessage, Data: "hello"}); err != nil {
|
||||
t.Fatalf("WriteJSON() error = %v", err)
|
||||
}
|
||||
var message OutgoingMessage
|
||||
if err := conn.ReadJSON(&message); err != nil {
|
||||
t.Fatalf("ReadJSON() error = %v", err)
|
||||
}
|
||||
if message.Type != EventMessage || message.Data != "hello" || message.Room != "test" {
|
||||
t.Fatalf("unexpected broadcast: %+v", message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ws
|
||||
|
||||
import "sync"
|
||||
|
||||
// Hub 管理房间及连接。它只负责单进程广播;跨实例广播可在此接入 Redis Pub/Sub。
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]struct{}
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{rooms: make(map[string]map[*Client]struct{})}
|
||||
}
|
||||
|
||||
func (h *Hub) Register(client *Client) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[client.room] == nil {
|
||||
h.rooms[client.room] = make(map[*Client]struct{})
|
||||
}
|
||||
h.rooms[client.room][client] = struct{}{}
|
||||
return len(h.rooms[client.room])
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(client *Client) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
clients, ok := h.rooms[client.room]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if _, ok := clients[client]; ok {
|
||||
delete(clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
remaining := len(clients)
|
||||
if remaining == 0 {
|
||||
delete(h.rooms, client.room)
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(room string, message OutgoingMessage) {
|
||||
h.mu.RLock()
|
||||
clients := h.rooms[room]
|
||||
stale := make([]*Client, 0)
|
||||
for client := range clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
default:
|
||||
stale = append(stale, client)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, client := range stale {
|
||||
h.Unregister(client)
|
||||
_ = client.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Count(room string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.rooms[room])
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ws
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHubRegisterBroadcastAndUnregister(t *testing.T) {
|
||||
hub := NewHub()
|
||||
client := &Client{hub: hub, room: "test", clientID: "one", send: make(chan OutgoingMessage, 1)}
|
||||
if online := hub.Register(client); online != 1 {
|
||||
t.Fatalf("Register() online = %d", online)
|
||||
}
|
||||
hub.Broadcast("test", OutgoingMessage{Type: EventMessage, Data: "hello"})
|
||||
message := <-client.send
|
||||
if message.Data != "hello" {
|
||||
t.Fatalf("Broadcast() data = %q", message.Data)
|
||||
}
|
||||
if online := hub.Unregister(client); online != 0 || hub.Count("test") != 0 {
|
||||
t.Fatalf("Unregister() online = %d", online)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ws
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
EventWelcome = "welcome"
|
||||
EventMessage = "message"
|
||||
EventPresence = "presence"
|
||||
EventError = "error"
|
||||
)
|
||||
|
||||
type IncomingMessage struct {
|
||||
Type string `json:"type" binding:"required" example:"message"`
|
||||
Data string `json:"data" binding:"required" example:"hello"`
|
||||
}
|
||||
|
||||
type OutgoingMessage struct {
|
||||
Type string `json:"type"`
|
||||
Room string `json:"room,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Online int `json:"online,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"skeleton/config"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
|
||||
)
|
||||
|
||||
func InitTracing(ctx context.Context, cfg config.ObservabilityConfig) (func(context.Context) error, error) {
|
||||
if !cfg.TracingEnabled {
|
||||
return func(context.Context) error { return nil }, nil
|
||||
}
|
||||
|
||||
options := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.TracingEndpoint)}
|
||||
if cfg.TracingInsecure {
|
||||
options = append(options, otlptracehttp.WithInsecure())
|
||||
}
|
||||
exporter, err := otlptracehttp.New(ctx, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.TracingService)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
|
||||
otel.SetTracerProvider(provider)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{}, propagation.Baggage{},
|
||||
))
|
||||
return provider.Shutdown, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"skeleton/modules/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(registerAuthPublicRoutes)
|
||||
RegisterPrivate(registerAuthPrivateRoutes)
|
||||
}
|
||||
|
||||
func registerAuthPublicRoutes(r *gin.RouterGroup) {
|
||||
ctrl := auth.NewController()
|
||||
group := r.Group("/auth")
|
||||
group.POST("/register", ctrl.Register)
|
||||
group.POST("/login", ctrl.Login)
|
||||
group.POST("/refresh", ctrl.Refresh)
|
||||
}
|
||||
|
||||
func registerAuthPrivateRoutes(r *gin.RouterGroup) {
|
||||
ctrl := auth.NewController()
|
||||
r.POST("/auth/logout", ctrl.Logout)
|
||||
}
|
||||
+60
-13
@@ -1,29 +1,76 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"skeleton/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type systemResponse struct {
|
||||
Code int `json:"code" example:"200"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type healthData struct {
|
||||
Status string `json:"status"`
|
||||
Database string `json:"database"`
|
||||
Redis string `json:"redis"`
|
||||
}
|
||||
|
||||
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("/ping", ping)
|
||||
r.GET("/health", health)
|
||||
}
|
||||
|
||||
r.GET("/health", func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "ok",
|
||||
"data": nil,
|
||||
})
|
||||
// ping godoc
|
||||
// @Summary 服务连通性检查
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} systemResponse
|
||||
// @Router /ping [get]
|
||||
func ping(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "pong",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
// health godoc
|
||||
// @Summary 服务健康检查
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} systemResponse{data=healthData}
|
||||
// @Failure 503 {object} systemResponse{data=healthData}
|
||||
// @Router /health [get]
|
||||
func health(ctx *gin.Context) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data := healthData{Status: "ok", Database: "up", Redis: "disabled"}
|
||||
status := http.StatusOK
|
||||
if err := database.Ping(checkCtx); err != nil {
|
||||
data.Status = "degraded"
|
||||
data.Database = "down"
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
if client := database.GetRedisClient(); client != nil {
|
||||
data.Redis = "up"
|
||||
if err := client.Ping(checkCtx); err != nil {
|
||||
data.Status = "degraded"
|
||||
data.Redis = "down"
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
ctx.JSON(status, gin.H{"code": status, "message": data.Status, "data": data})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"skeleton/routes/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -24,11 +26,22 @@ func SetupRoutes(cfg *config.Config) *gin.Engine {
|
||||
r.MaxMultipartMemory = 100 << 20 // 100 MB
|
||||
|
||||
// 添加自定义中间件
|
||||
r.Use(middlewares.RequestID())
|
||||
if cfg.Observability.TracingEnabled {
|
||||
r.Use(otelgin.Middleware(cfg.Observability.TracingService))
|
||||
}
|
||||
if cfg.Observability.MetricsEnabled {
|
||||
r.Use(middlewares.Metrics())
|
||||
}
|
||||
r.Use(middlewares.CORS()) // CORS跨域处理(需要在其他中间件之前)
|
||||
r.Use(middlewares.GinLogger()) // 结构化日志
|
||||
r.Use(middlewares.GinRecovery()) // 异常恢复
|
||||
r.Use(middlewares.ErrorLogging()) // 错误响应日志(用于记录逻辑异常)
|
||||
|
||||
if cfg.Observability.MetricsEnabled {
|
||||
r.GET(cfg.Observability.MetricsPath, gin.WrapH(promhttp.Handler()))
|
||||
}
|
||||
|
||||
// API 路由组
|
||||
api := r.Group("/api")
|
||||
|
||||
|
||||
+8
-2
@@ -1,8 +1,14 @@
|
||||
package ws
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
wsModule "skeleton/modules/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ApplyWebSocketRoutes WebSocket路由挂载点
|
||||
func ApplyWebSocketRoutes(r *gin.RouterGroup) {
|
||||
_ = r
|
||||
hub := wsModule.NewHub()
|
||||
controller := wsModule.NewController(hub)
|
||||
r.GET("/ws", controller.Connect)
|
||||
}
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
output_dir="$repo_root/dist"
|
||||
version="${VERSION:-dev}"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
platforms=(
|
||||
"linux amd64"
|
||||
"darwin amd64"
|
||||
"windows amd64"
|
||||
)
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
read -r target_os target_arch <<<"$platform"
|
||||
extension=""
|
||||
if [[ "$target_os" == "windows" ]]; then
|
||||
extension=".exe"
|
||||
fi
|
||||
output="$output_dir/skeleton-${target_os}-${target_arch}${extension}"
|
||||
echo "Building $output"
|
||||
CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" \
|
||||
go build -trimpath -ldflags "-s -w -X main.buildVersion=$version" -o "$output" .
|
||||
done
|
||||
|
||||
echo "Cross-platform amd64 binaries written to $output_dir"
|
||||
@@ -54,4 +54,16 @@ done < <(rg -l --hidden --glob '!**/.git/**' --glob '!**/.idea/**' "${old_module
|
||||
go mod edit -module "$new_module"
|
||||
go mod tidy
|
||||
|
||||
atlas_tool_dir="$repo_root/tools/atlas-loader"
|
||||
if [[ -f "$atlas_tool_dir/go.mod" ]]; then
|
||||
(
|
||||
cd "$atlas_tool_dir"
|
||||
go mod edit -droprequire "$old_module"
|
||||
go mod edit -require "$new_module@v0.0.0"
|
||||
go mod edit -dropreplace "$old_module"
|
||||
go mod edit -replace "$new_module=../.."
|
||||
go mod tidy
|
||||
)
|
||||
fi
|
||||
|
||||
echo "Initialization complete."
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
module skeleton-atlas-loader
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
ariga.io/atlas-provider-gorm v0.6.1
|
||||
skeleton v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
ariga.io/atlas v0.36.2-0.20250806044935-5bb51a0a956e // indirect
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.121.6 // indirect
|
||||
cloud.google.com/go/auth v0.16.4 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.8.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/spanner v1.84.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/googleapis/go-gorm-spanner v1.8.6 // indirect
|
||||
github.com/googleapis/go-sql-spanner v1.17.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.7.2 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.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/time v0.12.0 // indirect
|
||||
google.golang.org/api v0.247.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
|
||||
google.golang.org/grpc v1.74.2 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/driver/sqlserver v1.5.4 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
replace skeleton => ../..
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
@@ -13,11 +12,14 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
stmts, err := gormschema.New("postgres").Load(
|
||||
dialect := flag.String("dialect", "postgres", "database dialect: postgres, mysql, sqlite")
|
||||
flag.Parse()
|
||||
|
||||
stmts, err := gormschema.New(*dialect).Load(
|
||||
&models.User{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load gorm schema: %v", err)
|
||||
}
|
||||
io.WriteString(os.Stdout, stmts)
|
||||
_, _ = io.WriteString(os.Stdout, stmts)
|
||||
}
|
||||
Reference in New Issue
Block a user