Initial commit: MengStack Go API framework
Some checks failed
MengStack CI/CD / Build & Test (push) Failing after 44s
MengStack CI/CD / Deploy to Server (push) Has been skipped

This commit is contained in:
MengStack Dev 2026-10-02 23:55:35 +08:00
commit bc5c2889c0
65 changed files with 3376 additions and 0 deletions

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
# MengStack Environment Configuration
# Copy to .env and fill in actual values
# Server
MENGSTACK_SERVER_PORT=2222
MENGSTACK_SERVER_MODE=debug
# PostgreSQL
MENGSTACK_DATABASE_HOST=127.0.0.1
MENGSTACK_DATABASE_PORT=5432
MENGSTACK_DATABASE_USER=postgres
MENGSTACK_DATABASE_PASSWORD=
MENGSTACK_DATABASE_DBNAME=mengstack
MENGSTACK_DATABASE_SSLMODE=disable
# Redis
MENGSTACK_REDIS_ADDR=127.0.0.1:6379
MENGSTACK_REDIS_PASSWORD=
MENGSTACK_REDIS_DB=0
# JWT (MUST change in production)
MENGSTACK_JWT_SECRET=change-me-in-production
MENGSTACK_JWT_ACCESS_EXPIRY_MINUTES=30
MENGSTACK_JWT_REFRESH_EXPIRY_DAYS=7
MENGSTACK_JWT_ISSUER=mengstack
# Logging
MENGSTACK_LOG_LEVEL=debug
MENGSTACK_LOG_FORMAT=console

93
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,93 @@
name: MengStack CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
GO_VERSION: '1.23'
APP_NAME: mengstack
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Build
run: |
mkdir -p build
go build -o build/${{ env.APP_NAME }} ./cmd/server
- name: Test
run: go test ./... -v -race -coverprofile=coverage.out
- name: Upload coverage
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
retention-days: 7
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.APP_NAME }}-binary
path: build/${{ env.APP_NAME }}
retention-days: 7
deploy:
name: Deploy to Server
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.APP_NAME }}-binary
- name: Deploy via SSH
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: ${{ env.APP_NAME }}
target: /opt/mengstack/
- name: Restart service
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
chmod +x /opt/mengstack/${{ env.APP_NAME }}
systemctl restart mengstack
sleep 3
systemctl is-active mengstack

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
build/
bin/
# Build output
/dist/
# Environment
.env
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Uploads
/uploads/*
!/uploads/.gitkeep
# Logs
*.log
# Test
coverage.out
coverage.html
# Vendor (if not committed)
# /vendor/

62
AGENTS.md Normal file
View File

@ -0,0 +1,62 @@
# AGENTS.md — MengStack AI-Native Developer Guide
## Project Overview
MengStack is a multi-tenant SaaS development framework built with Go/Gin/PostgreSQL.
It follows a 3-layer architecture: **Kernel** (zero business semantics) → **Middle Platform** (shared capabilities) → **Applications** (business-specific).
## Architecture
```
internal/
kernel/ # Core shared packages (errors, response, model, tenant)
app/ # App infrastructure (database, cache, middleware, health)
modules/ # Business modules, each with 4 layers:
<module>/
domain/ # Entities + interfaces (ZERO external deps)
application/ # Business logic + DTOs
infrastructure/ # DB/Redis implementations
interfaces/ # HTTP handlers + routes + fx Module
```
## Key Rules
1. **Dependency direction**: interfaces → application → domain ← infrastructure
2. **Domain layer has ZERO external imports** (no gin, gorm, zap, redis)
3. **tenantID is always an explicit method parameter** — never from context in domain layer
4. **All DB queries must include tenant_id** — multi-tenant isolation is mandatory
5. **All responses use kernel/response** — unified format with trace_id
6. **All errors use kernel/errors** — error codes, not raw strings
7. **bcrypt cost ≥ 12** for password hashing
8. **Redis keys must include tenant_id**: `{app}:{module}:{tenant_id}:{biz}:{id}`
## Tech Stack
- Go 1.23+ / Gin / GORM v2 / PostgreSQL 16 / Redis 7
- uber-go/fx for dependency injection
- uber-go/zap for structured logging
- spf13/viper for configuration
- golang-jwt/jwt/v5 for JWT
## Commands
- `make dev` — Run development server
- `make test` — Run all tests with race detection
- `make build` — Build binary
- `make swagger` — Regenerate Swagger API docs (requires `swag` CLI)
- `make docker-up` — Start PostgreSQL + Redis via Docker Compose
- `make lint` — Run golangci-lint
## Configuration
- Config files: `configs/config.yaml` (base) + `configs/config.{env}.yaml` (overrides)
- Environment variables: prefix `MENGSTACK_`, separator `_` (e.g., `MENGSTACK_DATABASE_HOST`)
- Sensitive values: environment variables only, never in config files or git
## Adding a New Module
1. Create `internal/modules/<name>/` with 4 subdirectories
2. Define entities and interfaces in `domain/` (no external deps)
3. Implement business logic in `application/`
4. Implement repositories in `infrastructure/` (uses GORM)
5. Create HTTP handlers and routes in `interfaces/`
6. Export `fx.Module` from `interfaces/module.go`
7. Add module to `internal/app/app.go` NewApp()
## Testing
- Unit tests: `_test.go` files alongside source (same package)
- Integration tests: `test/` directory
- Always test multi-tenant isolation (two tenants, verify data separation)

41
Makefile Normal file
View File

@ -0,0 +1,41 @@
.PHONY: build run test clean lint dev swagger
APP_NAME=mengstack
BUILD_DIR=build
build:
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/server
run:
go run ./cmd/server
dev:
go run ./cmd/server
test:
go test ./... -v -race
test-cover:
go test ./... -v -race -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
swagger:
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
clean:
rm -rf $(BUILD_DIR) coverage.out coverage.html
lint:
golangci-lint run ./...
tidy:
go mod tidy
docker-up:
docker-compose up -d
docker-down:
docker-compose down
docker-logs:
docker-compose logs -f

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/logo/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
assets/logo/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
assets/logo/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

29
cmd/server/main.go Normal file
View File

@ -0,0 +1,29 @@
// MengStack API
//
// @title MengStack API
// @version 0.1.0
// @description MengStack 平台 API 文档
//
// @host localhost:2222
// @BasePath /api/v1
//
// @securityDefinitions.apiKey Bearer
// @in header
// @name Authorization
// @description Bearer <token>
//
// @securityDefinitions.apiKey TenantID
// @in header
// @name X-Tenant-ID
// @description 租户 ID(受保护接口必填)
package main
import (
"mengstack/internal/app"
_ "mengstack/docs"
)
func main() {
app.NewApp().Run()
}

26
configs/config.demo.yaml Normal file
View File

@ -0,0 +1,26 @@
server:
port: 2223
mode: demo
database:
host: 127.0.0.1
port: 5432
user: mengstack_demo
password: "MengStack2026!Demo"
dbname: mengstack_demo
sslmode: disable
redis:
addr: 127.0.0.1:6379
password: "F4J7rcollz2NEybF"
db: 1
jwt:
secret: mengstack-demo-secret-key-2026
access_expiry_minutes: 30
refresh_expiry_days: 7
issuer: mengstack-demo
log:
level: info
format: json

6
configs/config.dev.yaml Normal file
View File

@ -0,0 +1,6 @@
server:
mode: debug
log:
level: debug
format: console

6
configs/config.prod.yaml Normal file
View File

@ -0,0 +1,6 @@
server:
mode: release
log:
level: info
format: json

26
configs/config.yaml Normal file
View File

@ -0,0 +1,26 @@
server:
port: 2222
mode: debug
database:
host: 127.0.0.1
port: 5432
user: postgres
password: ""
dbname: mengstack
sslmode: disable
redis:
addr: 127.0.0.1:6379
password: ""
db: 0
jwt:
secret: change-me-in-production
access_expiry_minutes: 30
refresh_expiry_days: 7
issuer: mengstack
log:
level: info
format: json

41
deploy.sh Normal file
View File

@ -0,0 +1,41 @@
#!/bin/bash
# MengStack Demo 一键部署脚本
# 用法: ./deploy.sh
set -e
APP_DIR="/www/wwwroot/mengstack-demo"
SERVICE_NAME="mengstack-demo"
BINARY_NAME="mengstack-demo"
GO_PATH="/usr/local/go/bin"
export GOPROXY=https://goproxy.cn,direct
echo "=== MengStack Demo 部署开始 ==="
# 1. 拉取最新代码
echo "[1/4] 拉取最新代码..."
cd "$APP_DIR"
git pull origin master
# 2. 构建二进制
echo "[2/4] 构建 Go 二进制..."
export PATH="$GO_PATH:$PATH"
go build -o "$BINARY_NAME" ./cmd/server/
# 3. 重启服务
echo "[3/4] 重启服务..."
systemctl restart "$SERVICE_NAME"
# 4. 等待并检查状态
sleep 3
echo "[4/4] 检查服务状态..."
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo "=== 部署成功 ==="
echo "服务状态: $(systemctl is-active $SERVICE_NAME)"
curl -s http://localhost:2223/health | head -c 200
echo ""
else
echo "=== 部署失败 ==="
journalctl -u "$SERVICE_NAME" --no-pager -n 20
exit 1
fi

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: mengstack
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mengstack_dev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:

514
docs/docs.go Normal file
View File

@ -0,0 +1,514 @@
// Package docs 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": {
"description": "使用邮箱和密码登录,返回 JWT token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "用户登录",
"parameters": [
{
"description": "登录信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "使用 refresh_token 获取新的 token 对",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "刷新 Token",
"parameters": [
{
"description": "{ \\",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/auth/register": {
"post": {
"description": "使用邮箱、用户名和密码注册新用户,返回 JWT token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "用户注册",
"parameters": [
{
"description": "注册信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/health": {
"get": {
"description": "检查 PostgreSQL 和 Redis 连接状态",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "健康检查",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"properties": {
"database": {
"type": "string"
},
"redis": {
"type": "string"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "integer"
},
"trace_id": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
}
},
"/password": {
"post": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "使用旧密码验证后设置新密码",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "修改密码",
"parameters": [
{
"description": "密码修改请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/ping": {
"get": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "返回 pong,用于验证认证和多租户中间件是否正常",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "健康探测",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/profile": {
"get": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "返回当前认证用户的个人资料",
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "获取当前用户信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.UserDTO"
}
}
}
]
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
}
},
"definitions": {
"mengstack_internal_kernel_response.Response": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"data": {},
"message": {
"type": "string"
},
"trace_id": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.ChangePasswordRequest": {
"type": "object",
"required": [
"new_password",
"old_password"
],
"properties": {
"new_password": {
"type": "string",
"maxLength": 128,
"minLength": 8
},
"old_password": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.LoginRequest": {
"type": "object",
"required": [
"email",
"password"
],
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.RegisterRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string"
},
"nickname": {
"type": "string"
},
"password": {
"type": "string",
"maxLength": 128,
"minLength": 8
},
"username": {
"type": "string",
"maxLength": 64,
"minLength": 3
}
}
},
"mengstack_internal_modules_auth_domain.TokenPair": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"expires_in": {
"type": "integer"
},
"refresh_token": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.UserDTO": {
"type": "object",
"properties": {
"avatar": {
"type": "string"
},
"email": {
"type": "string"
},
"id": {
"type": "integer"
},
"nickname": {
"type": "string"
},
"status": {
"type": "integer"
},
"tenant_id": {
"type": "string"
},
"username": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "Bearer \u003ctoken\u003e",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"TenantID": {
"description": "租户 ID(受保护接口必填)",
"type": "apiKey",
"name": "X-Tenant-ID",
"in": "header"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "0.1.0",
Host: "localhost:2222",
BasePath: "/api/v1",
Schemes: []string{},
Title: "MengStack API",
Description: "MengStack 平台 API 文档",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}

490
docs/swagger.json Normal file
View File

@ -0,0 +1,490 @@
{
"swagger": "2.0",
"info": {
"description": "MengStack 平台 API 文档",
"title": "MengStack API",
"contact": {},
"version": "0.1.0"
},
"host": "localhost:2222",
"basePath": "/api/v1",
"paths": {
"/auth/login": {
"post": {
"description": "使用邮箱和密码登录,返回 JWT token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "用户登录",
"parameters": [
{
"description": "登录信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/auth/refresh": {
"post": {
"description": "使用 refresh_token 获取新的 token 对",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "刷新 Token",
"parameters": [
{
"description": "{ \\",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/auth/register": {
"post": {
"description": "使用邮箱、用户名和密码注册新用户,返回 JWT token",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "用户注册",
"parameters": [
{
"description": "注册信息",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/health": {
"get": {
"description": "检查 PostgreSQL 和 Redis 连接状态",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "健康检查",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"properties": {
"database": {
"type": "string"
},
"redis": {
"type": "string"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "integer"
},
"trace_id": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
}
},
"/password": {
"post": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "使用旧密码验证后设置新密码",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "修改密码",
"parameters": [
{
"description": "密码修改请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/ping": {
"get": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "返回 pong,用于验证认证和多租户中间件是否正常",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "健康探测",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
},
"/profile": {
"get": {
"security": [
{
"Bearer": []
},
{
"TenantID": []
}
],
"description": "返回当前认证用户的个人资料",
"produces": [
"application/json"
],
"tags": [
"Auth"
],
"summary": "获取当前用户信息",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/mengstack_internal_modules_auth_domain.UserDTO"
}
}
}
]
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/mengstack_internal_kernel_response.Response"
}
}
}
}
}
},
"definitions": {
"mengstack_internal_kernel_response.Response": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"data": {},
"message": {
"type": "string"
},
"trace_id": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.ChangePasswordRequest": {
"type": "object",
"required": [
"new_password",
"old_password"
],
"properties": {
"new_password": {
"type": "string",
"maxLength": 128,
"minLength": 8
},
"old_password": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.LoginRequest": {
"type": "object",
"required": [
"email",
"password"
],
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.RegisterRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string"
},
"nickname": {
"type": "string"
},
"password": {
"type": "string",
"maxLength": 128,
"minLength": 8
},
"username": {
"type": "string",
"maxLength": 64,
"minLength": 3
}
}
},
"mengstack_internal_modules_auth_domain.TokenPair": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"expires_in": {
"type": "integer"
},
"refresh_token": {
"type": "string"
}
}
},
"mengstack_internal_modules_auth_domain.UserDTO": {
"type": "object",
"properties": {
"avatar": {
"type": "string"
},
"email": {
"type": "string"
},
"id": {
"type": "integer"
},
"nickname": {
"type": "string"
},
"status": {
"type": "integer"
},
"tenant_id": {
"type": "string"
},
"username": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"Bearer": {
"description": "Bearer \u003ctoken\u003e",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"TenantID": {
"description": "租户 ID(受保护接口必填)",
"type": "apiKey",
"name": "X-Tenant-ID",
"in": "header"
}
}
}

307
docs/swagger.yaml Normal file
View File

@ -0,0 +1,307 @@
basePath: /api/v1
definitions:
mengstack_internal_kernel_response.Response:
properties:
code:
type: integer
data: {}
message:
type: string
trace_id:
type: string
type: object
mengstack_internal_modules_auth_domain.ChangePasswordRequest:
properties:
new_password:
maxLength: 128
minLength: 8
type: string
old_password:
type: string
required:
- new_password
- old_password
type: object
mengstack_internal_modules_auth_domain.LoginRequest:
properties:
email:
type: string
password:
type: string
required:
- email
- password
type: object
mengstack_internal_modules_auth_domain.RegisterRequest:
properties:
email:
type: string
nickname:
type: string
password:
maxLength: 128
minLength: 8
type: string
username:
maxLength: 64
minLength: 3
type: string
required:
- email
- password
- username
type: object
mengstack_internal_modules_auth_domain.TokenPair:
properties:
access_token:
type: string
expires_in:
type: integer
refresh_token:
type: string
type: object
mengstack_internal_modules_auth_domain.UserDTO:
properties:
avatar:
type: string
email:
type: string
id:
type: integer
nickname:
type: string
status:
type: integer
tenant_id:
type: string
username:
type: string
type: object
host: localhost:2222
info:
contact: {}
description: MengStack 平台 API 文档
title: MengStack API
version: 0.1.0
paths:
/auth/login:
post:
consumes:
- application/json
description: 使用邮箱和密码登录,返回 JWT token
parameters:
- description: 登录信息
in: body
name: request
required: true
schema:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.LoginRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/mengstack_internal_kernel_response.Response'
- properties:
data:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
summary: 用户登录
tags:
- Auth
/auth/refresh:
post:
consumes:
- application/json
description: 使用 refresh_token 获取新的 token 对
parameters:
- description: '{ \'
in: body
name: request
required: true
schema:
type: object
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/mengstack_internal_kernel_response.Response'
- properties:
data:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
summary: 刷新 Token
tags:
- Auth
/auth/register:
post:
consumes:
- application/json
description: 使用邮箱、用户名和密码注册新用户,返回 JWT token
parameters:
- description: 注册信息
in: body
name: request
required: true
schema:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/mengstack_internal_kernel_response.Response'
- properties:
data:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"409":
description: Conflict
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
summary: 用户注册
tags:
- Auth
/health:
get:
description: 检查 PostgreSQL 和 Redis 连接状态
produces:
- application/json
responses:
"200":
description: OK
schema:
properties:
database:
type: string
redis:
type: string
status:
type: string
timestamp:
type: integer
trace_id:
type: string
version:
type: string
type: object
summary: 健康检查
tags:
- System
/password:
post:
consumes:
- application/json
description: 使用旧密码验证后设置新密码
parameters:
- description: 密码修改请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
security:
- Bearer: []
- TenantID: []
summary: 修改密码
tags:
- Auth
/ping:
get:
description: 返回 pong,用于验证认证和多租户中间件是否正常
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
security:
- Bearer: []
- TenantID: []
summary: 健康探测
tags:
- System
/profile:
get:
description: 返回当前认证用户的个人资料
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/mengstack_internal_kernel_response.Response'
- properties:
data:
$ref: '#/definitions/mengstack_internal_modules_auth_domain.UserDTO'
type: object
"401":
description: Unauthorized
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
"404":
description: Not Found
schema:
$ref: '#/definitions/mengstack_internal_kernel_response.Response'
security:
- Bearer: []
- TenantID: []
summary: 获取当前用户信息
tags:
- Auth
securityDefinitions:
Bearer:
description: Bearer <token>
in: header
name: Authorization
type: apiKey
TenantID:
description: 租户 ID(受保护接口必填)
in: header
name: X-Tenant-ID
type: apiKey
swagger: "2.0"

86
go.mod Normal file
View File

@ -0,0 +1,86 @@
module mengstack
go 1.23.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.7.0
github.com/spf13/viper v1.19.0
go.uber.org/fx v1.23.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.36.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.18.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.12.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // 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.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // 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-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

243
go.sum Normal file
View File

@ -0,0 +1,243 @@
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/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.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
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.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.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.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw=
go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg=
go.uber.org/fx v1.23.0/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/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/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

102
internal/app/app.go Normal file
View File

@ -0,0 +1,102 @@
package app
import (
"context"
"fmt"
"net/http"
"time"
"mengstack/internal/app/cache"
"mengstack/internal/app/database"
"mengstack/internal/app/health"
"mengstack/internal/app/middleware"
"mengstack/internal/config"
"mengstack/internal/logger"
authinterfaces "mengstack/internal/modules/auth/interfaces"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
var Module = fx.Module("app",
fx.Provide(newEngine),
fx.Provide(newServer),
fx.Invoke(registerLifecycle),
)
func newEngine(
cfg *config.Config,
log *zap.Logger,
db *gorm.DB,
rdb *redis.Client,
authHandler *authinterfaces.Handler,
authMW gin.HandlerFunc,
) *gin.Engine {
ginMode := "release"
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
ginMode = "debug"
}
gin.SetMode(ginMode)
r := gin.New()
r.Use(middleware.RequestID())
r.Use(middleware.RequestLogger())
r.Use(middleware.CORS())
r.Use(gin.Recovery())
healthHandler := health.NewHandler(db, rdb, log)
r.GET("/health", healthHandler.Handle)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
authinterfaces.SetupRoutes(r, authHandler, authMW)
return r
}
func newServer(cfg *config.Config, engine *gin.Engine) *http.Server {
return &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: engine,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
log.Info("server starting", zap.String("addr", srv.Addr))
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("server error", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
log.Info("server shutting down")
return srv.Shutdown(ctx)
},
})
}
func NewApp() *fx.App {
return fx.New(
fx.Provide(
func() (*config.Config, error) { return config.Load() },
func(cfg *config.Config) (*zap.Logger, error) { return logger.New(cfg.Log) },
),
database.Module,
cache.Module,
authinterfaces.Module,
Module,
)
}

14
internal/app/cache/module.go vendored Normal file
View File

@ -0,0 +1,14 @@
package cache
import (
"mengstack/internal/config"
"go.uber.org/fx"
"github.com/redis/go-redis/v9"
)
var Module = fx.Module("cache",
fx.Provide(func(cfg *config.Config) (*redis.Client, error) {
return NewRedis(cfg.Redis)
}),
)

24
internal/app/cache/redis.go vendored Normal file
View File

@ -0,0 +1,24 @@
package cache
import (
"context"
"fmt"
"mengstack/internal/config"
"github.com/redis/go-redis/v9"
)
func NewRedis(cfg config.RedisConfig) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("connect to Redis: %w", err)
}
return rdb, nil
}

View File

@ -0,0 +1,14 @@
package database
import (
"mengstack/internal/config"
"go.uber.org/fx"
"gorm.io/gorm"
)
var Module = fx.Module("database",
fx.Provide(func(cfg *config.Config) (*gorm.DB, error) {
return NewPostgres(cfg.Database)
}),
)

View File

@ -0,0 +1,37 @@
package database
import (
"fmt"
"time"
"mengstack/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func NewPostgres(cfg config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("connect to PostgreSQL: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
}

View File

@ -0,0 +1,57 @@
package health
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
rdb *redis.Client
log *zap.Logger
}
func NewHandler(db *gorm.DB, rdb *redis.Client, log *zap.Logger) *Handler {
return &Handler{db: db, rdb: rdb, log: log}
}
// Handle godoc
// @Summary 健康检查
// @Description 检查 PostgreSQL 和 Redis 连接状态
// @Tags System
// @Produce json
// @Success 200 {object} object{status=string,database=string,redis=string,timestamp=integer,version=string,trace_id=string}
// @Router /health [get]
func (h *Handler) Handle(c *gin.Context) {
status := "ok"
dbStatus := "connected"
redisStatus := "connected"
sqlDB, err := h.db.DB()
if err != nil || sqlDB.Ping() != nil {
status = "degraded"
dbStatus = "disconnected"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if h.rdb.Ping(ctx).Err() != nil {
status = "degraded"
redisStatus = "disconnected"
}
c.JSON(http.StatusOK, gin.H{
"status": status,
"database": dbStatus,
"redis": redisStatus,
"timestamp": time.Now().Unix(),
"version": "0.1.0",
"trace_id": c.GetString("trace_id"),
})
}

View File

@ -0,0 +1,38 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := c.GetHeader("X-Request-ID")
if traceID == "" {
traceID = uuid.New().String()
}
c.Set("trace_id", traceID)
c.Header("X-Request-ID", traceID)
c.Next()
}
}
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
gin.DefaultWriter.Write([]byte(
"[" + c.GetString("trace_id") + "] " +
c.Request.Method + " " + path + " " +
strconv.Itoa(status) + " " + latency.String() + "\n",
))
}
}

View File

@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/errors"
"mengstack/internal/kernel/tenant"
"github.com/gin-gonic/gin"
)
func MultiTenant() gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID == "" {
tenantID = c.Query("tenant_id")
}
if tenantID == "" {
response.Fail(c, errors.ErrTenantRequired)
c.Abort()
return
}
c.Set("tenant_id", tenantID)
ctx := tenant.WithTenantID(c.Request.Context(), tenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func OptionalTenant() gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID == "" {
tenantID = c.Query("tenant_id")
}
if tenantID != "" {
c.Set("tenant_id", tenantID)
ctx := tenant.WithTenantID(c.Request.Context(), tenantID)
c.Request = c.Request.WithContext(ctx)
}
c.Next()
}
}
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, X-Tenant-ID, X-Request-ID")
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

107
internal/config/config.go Normal file
View File

@ -0,0 +1,107 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"`
}
type RedisConfig struct {
Addr string `mapstructure:"addr"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
AccessExpiryMinutes int `mapstructure:"access_expiry_minutes"`
RefreshExpiryDays int `mapstructure:"refresh_expiry_days"`
Issuer string `mapstructure:"issuer"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
func Load() (*Config, error) {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("./configs/")
v.AddConfigPath(".")
v.SetEnvPrefix("MENGSTACK")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
setDefaults(v)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
// Check env var for mode override before merging env config
env := v.GetString("server.mode")
if envOverride := os.Getenv("MENGSTACK_SERVER_MODE"); envOverride != "" {
env = envOverride
}
mergeEnvConfig(v, env)
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &cfg, nil
}
func setDefaults(v *viper.Viper) {
v.SetDefault("server.port", 8080)
v.SetDefault("server.mode", "debug")
v.SetDefault("database.host", "127.0.0.1")
v.SetDefault("database.port", 5432)
v.SetDefault("database.user", "postgres")
v.SetDefault("database.dbname", "mengstack")
v.SetDefault("database.sslmode", "disable")
v.SetDefault("redis.addr", "127.0.0.1:6379")
v.SetDefault("redis.db", 0)
v.SetDefault("jwt.access_expiry_minutes", 30)
v.SetDefault("jwt.refresh_expiry_days", 7)
v.SetDefault("jwt.issuer", "mengstack")
v.SetDefault("log.level", "info")
v.SetDefault("log.format", "json")
}
func mergeEnvConfig(v *viper.Viper, env string) {
envFile := fmt.Sprintf("config.%s", env)
v.SetConfigName(envFile)
if err := v.MergeInConfig(); err != nil {
// environment-specific config is optional
}
v.SetConfigName("config")
}

View File

@ -0,0 +1,57 @@
package errors
import (
"errors"
"fmt"
"net/http"
)
type AppError struct {
Code string
Message string
HTTPCode int
Cause error
}
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Cause
}
func New(code, message string, httpCode int) *AppError {
return &AppError{Code: code, Message: message, HTTPCode: httpCode}
}
func Wrap(cause error, code, message string, httpCode int) *AppError {
return &AppError{Code: code, Message: message, HTTPCode: httpCode, Cause: cause}
}
func IsAppError(err error) (*AppError, bool) {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr, true
}
return nil, false
}
var (
ErrInvalidParam = New("INVALID_PARAM", "invalid parameter", http.StatusBadRequest)
ErrUnauthorized = New("UNAUTHORIZED", "unauthorized", http.StatusUnauthorized)
ErrForbidden = New("FORBIDDEN", "forbidden", http.StatusForbidden)
ErrNotFound = New("NOT_FOUND", "resource not found", http.StatusNotFound)
ErrConflict = New("CONFLICT", "resource already exists", http.StatusConflict)
ErrInternal = New("INTERNAL_ERROR", "internal server error", http.StatusInternalServerError)
ErrTenantRequired = New("TENANT_REQUIRED", "tenant context required", http.StatusBadRequest)
ErrTokenExpired = New("TOKEN_EXPIRED", "token expired", http.StatusUnauthorized)
ErrTokenInvalid = New("TOKEN_INVALID", "invalid token", http.StatusUnauthorized)
ErrPasswordWrong = New("PASSWORD_WRONG", "wrong password", http.StatusUnauthorized)
ErrUserNotFound = New("USER_NOT_FOUND", "user not found", http.StatusNotFound)
ErrUserExists = New("USER_EXISTS", "user already exists", http.StatusConflict)
ErrRefreshToken = New("REFRESH_TOKEN_INVALID", "invalid refresh token", http.StatusUnauthorized)
)

View File

@ -0,0 +1,35 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type BaseModel struct {
ID uint `json:"id" gorm:"primaryKey"`
TenantID string `json:"tenant_id" gorm:"type:uuid;not null;index;primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
CreatorID uint `json:"creator_id"`
}
type Tenant struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
Slug string `json:"slug" gorm:"uniqueIndex;size:64;not null"`
Status int `json:"status" gorm:"default:1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (Tenant) TableName() string {
return "tenants"
}
func NewTenantID() string {
return uuid.New().String()
}

View File

@ -0,0 +1,53 @@
package response
import (
"net/http"
"mengstack/internal/kernel/errors"
"github.com/gin-gonic/gin"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
TraceID string `json:"trace_id"`
}
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Code: 0,
Message: "success",
Data: data,
TraceID: c.GetString("trace_id"),
})
}
func Fail(c *gin.Context, err *errors.AppError) {
c.JSON(err.HTTPCode, Response{
Code: -1,
Message: err.Message,
TraceID: c.GetString("trace_id"),
})
}
func FailWithMessage(c *gin.Context, err *errors.AppError, message string) {
c.JSON(err.HTTPCode, Response{
Code: -1,
Message: message,
TraceID: c.GetString("trace_id"),
})
}
func HandleError(c *gin.Context, err error) {
if appErr, ok := errors.IsAppError(err); ok {
Fail(c, appErr)
return
}
Fail(c, errors.ErrInternal)
}
func NewBadRequest(msg string) *errors.AppError {
return errors.New("BAD_REQUEST", msg, http.StatusBadRequest)
}

View File

@ -0,0 +1,21 @@
package tenant
import "context"
type ctxKey string
const tenantIDKey ctxKey = "tenant_id"
func WithTenantID(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, tenantIDKey, tenantID)
}
func FromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(tenantIDKey).(string)
return id, ok && id != ""
}
func MustFromContext(ctx context.Context) string {
id, _ := FromContext(ctx)
return id
}

34
internal/logger/logger.go Normal file
View File

@ -0,0 +1,34 @@
package logger
import (
"os"
"mengstack/internal/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg config.LogConfig) (*zap.Logger, error) {
var level zapcore.Level
if err := level.UnmarshalText([]byte(cfg.Level)); err != nil {
level = zapcore.InfoLevel
}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "time"
encoderConfig.MessageKey = "msg"
encoderConfig.LevelKey = "level"
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
encoderConfig.EncodeLevel = zapcore.LowercaseLevelEncoder
var encoder zapcore.Encoder
if cfg.Format == "console" {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
} else {
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
core := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), level)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
}

View File

@ -0,0 +1,46 @@
package application
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID uint `json:"user_id"`
TenantID string `json:"tenant_id"`
Type string `json:"type"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, tenantID, tokenType string, expiry time.Duration, secret, issuer string) (string, error) {
claims := Claims{
UserID: userID,
TenantID: tenantID,
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ParseToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, jwt.ErrTokenInvalidClaims
}
return claims, nil
}

View File

@ -0,0 +1,177 @@
package application
import (
"context"
"time"
"mengstack/internal/kernel/errors"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/auth/domain"
"golang.org/x/crypto/bcrypt"
)
type Service struct {
repo domain.UserRepository
jwtCfg JWTConfig
}
type JWTConfig struct {
Secret string
AccessExpiryMinutes int
RefreshExpiryDays int
Issuer string
}
func NewService(repo domain.UserRepository, jwtCfg JWTConfig) *Service {
return &Service{repo: repo, jwtCfg: jwtCfg}
}
func (s *Service) Register(ctx context.Context, req domain.RegisterRequest) (domain.TokenPair, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.TokenPair{}, errors.ErrTenantRequired
}
if _, err := s.repo.FindByEmail(ctx, tenantID, req.Email); err == nil {
return domain.TokenPair{}, errors.ErrUserExists
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
return domain.TokenPair{}, errors.Wrap(err, "HASH_FAILED", "failed to hash password", 500)
}
user := &domain.User{
TenantID: tenantID,
Username: req.Username,
Email: req.Email,
Password: string(hash),
Nickname: req.Nickname,
Status: 1,
}
if err := s.repo.Create(ctx, user); err != nil {
return domain.TokenPair{}, err
}
return s.generateTokens(user)
}
func (s *Service) Login(ctx context.Context, req domain.LoginRequest) (domain.TokenPair, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.TokenPair{}, errors.ErrTenantRequired
}
user, err := s.repo.FindByEmail(ctx, tenantID, req.Email)
if err != nil {
return domain.TokenPair{}, errors.ErrUnauthorized
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return domain.TokenPair{}, errors.ErrPasswordWrong
}
if user.Status != 1 {
return domain.TokenPair{}, errors.ErrForbidden
}
tokens, err := s.generateTokens(user)
if err != nil {
return domain.TokenPair{}, err
}
now := time.Now()
user.LastLogin = &now
_ = s.repo.Update(ctx, user)
return tokens, nil
}
func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (domain.TokenPair, error) {
claims, err := ParseToken(refreshToken, s.jwtCfg.Secret)
if err != nil {
return domain.TokenPair{}, errors.ErrRefreshToken
}
if claims.Type != "refresh" {
return domain.TokenPair{}, errors.ErrRefreshToken
}
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.TokenPair{}, errors.ErrTenantRequired
}
user, err := s.repo.FindByID(ctx, tenantID, claims.UserID)
if err != nil {
return domain.TokenPair{}, errors.ErrUnauthorized
}
return s.generateTokens(user)
}
func (s *Service) ChangePassword(ctx context.Context, userID uint, req domain.ChangePasswordRequest) error {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return errors.ErrTenantRequired
}
user, err := s.repo.FindByID(ctx, tenantID, userID)
if err != nil {
return errors.ErrUserNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
return errors.ErrPasswordWrong
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
if err != nil {
return errors.Wrap(err, "HASH_FAILED", "failed to hash password", 500)
}
user.Password = string(hash)
return s.repo.Update(ctx, user)
}
func (s *Service) GetProfile(ctx context.Context, userID uint) (domain.UserDTO, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.UserDTO{}, errors.ErrTenantRequired
}
user, err := s.repo.FindByID(ctx, tenantID, userID)
if err != nil {
return domain.UserDTO{}, errors.ErrUserNotFound
}
return domain.ToUserDTO(user), nil
}
func (s *Service) JWTSecret() string {
return s.jwtCfg.Secret
}
func (s *Service) generateTokens(user *domain.User) (domain.TokenPair, error) {
access, err := GenerateToken(user.ID, user.TenantID, "access",
time.Duration(s.jwtCfg.AccessExpiryMinutes)*time.Minute,
s.jwtCfg.Secret, s.jwtCfg.Issuer,
)
if err != nil {
return domain.TokenPair{}, errors.Wrap(err, "TOKEN_GEN_FAILED", "failed to generate token", 500)
}
refresh, err := GenerateToken(user.ID, user.TenantID, "refresh",
time.Duration(s.jwtCfg.RefreshExpiryDays)*24*time.Hour,
s.jwtCfg.Secret, s.jwtCfg.Issuer,
)
if err != nil {
return domain.TokenPair{}, errors.Wrap(err, "TOKEN_GEN_FAILED", "failed to generate refresh token", 500)
}
return domain.TokenPair{
AccessToken: access,
RefreshToken: refresh,
ExpiresIn: s.jwtCfg.AccessExpiryMinutes * 60,
}, nil
}

View File

@ -0,0 +1,46 @@
package domain
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=128"`
Nickname string `json:"nickname"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=128"`
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
type UserDTO struct {
ID uint `json:"id"`
TenantID string `json:"tenant_id"`
Username string `json:"username"`
Email string `json:"email"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Status int `json:"status"`
}
func ToUserDTO(u *User) UserDTO {
return UserDTO{
ID: u.ID,
TenantID: u.TenantID,
Username: u.Username,
Email: u.Email,
Nickname: u.Nickname,
Avatar: u.Avatar,
Status: u.Status,
}
}

View File

@ -0,0 +1,11 @@
package domain
import "context"
type UserRepository interface {
Create(ctx context.Context, user *User) error
FindByID(ctx context.Context, tenantID string, id uint) (*User, error)
FindByEmail(ctx context.Context, tenantID string, email string) (*User, error)
FindByUsername(ctx context.Context, tenantID string, username string) (*User, error)
Update(ctx context.Context, user *User) error
}

View File

@ -0,0 +1,21 @@
package domain
import "time"
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
TenantID string `json:"tenant_id" gorm:"type:uuid;not null;index:idx_tenant_email,unique;primaryKey"`
Username string `json:"username" gorm:"uniqueIndex;size:64;not null"`
Email string `json:"email" gorm:"index:idx_tenant_email,unique;size:128;not null"`
Password string `json:"-" gorm:"size:256;not null"`
Nickname string `json:"nickname" gorm:"size:64"`
Avatar string `json:"avatar" gorm:"size:512"`
Status int `json:"status" gorm:"default:1"`
LastLogin *time.Time `json:"last_login,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string {
return "users"
}

View File

@ -0,0 +1,11 @@
package infrastructure
import (
"mengstack/internal/modules/auth/domain"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(&domain.User{})
}

View File

@ -0,0 +1,52 @@
package infrastructure
import (
"context"
"mengstack/internal/modules/auth/domain"
"gorm.io/gorm"
)
type userRepo struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &userRepo{db: db}
}
func (r *userRepo) Create(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *userRepo) FindByID(ctx context.Context, tenantID string, id uint) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepo) FindByEmail(ctx context.Context, tenantID string, email string) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).Where("email = ? AND tenant_id = ?", email, tenantID).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepo) FindByUsername(ctx context.Context, tenantID string, username string) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).Where("username = ? AND tenant_id = ?", username, tenantID).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepo) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Save(user).Error
}

View File

@ -0,0 +1,166 @@
package interfaces
import (
"mengstack/internal/kernel/response"
"mengstack/internal/modules/auth/application"
"mengstack/internal/modules/auth/domain"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *application.Service
}
func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc}
}
// Register godoc
// @Summary 用户注册
// @Description 使用邮箱、用户名和密码注册新用户,返回 JWT token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body domain.RegisterRequest true "注册信息"
// @Success 200 {object} response.Response{data=domain.TokenPair}
// @Failure 400 {object} response.Response
// @Failure 409 {object} response.Response
// @Router /auth/register [post]
func (h *Handler) Register(c *gin.Context) {
var req domain.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
tokens, err := h.svc.Register(c.Request.Context(), req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, tokens)
}
// Login godoc
// @Summary 用户登录
// @Description 使用邮箱和密码登录,返回 JWT token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body domain.LoginRequest true "登录信息"
// @Success 200 {object} response.Response{data=domain.TokenPair}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /auth/login [post]
func (h *Handler) Login(c *gin.Context) {
var req domain.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
tokens, err := h.svc.Login(c.Request.Context(), req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, tokens)
}
// RefreshToken godoc
// @Summary 刷新 Token
// @Description 使用 refresh_token 获取新的 token 对
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body object true "{ \"refresh_token\": \"...\" }"
// @Success 200 {object} response.Response{data=domain.TokenPair}
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /auth/refresh [post]
func (h *Handler) RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
tokens, err := h.svc.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, tokens)
}
// ChangePassword godoc
// @Summary 修改密码
// @Description 使用旧密码验证后设置新密码
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body domain.ChangePasswordRequest true "密码修改请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /password [post]
func (h *Handler) ChangePassword(c *gin.Context) {
userID := c.GetUint("user_id")
var req domain.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
if err := h.svc.ChangePassword(c.Request.Context(), userID, req); err != nil {
response.HandleError(c, err)
return
}
response.Success(c, nil)
}
// GetProfile godoc
// @Summary 获取当前用户信息
// @Description 返回当前认证用户的个人资料
// @Tags Auth
// @Produce json
// @Success 200 {object} response.Response{data=domain.UserDTO}
// @Failure 401 {object} response.Response
// @Failure 404 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /profile [get]
func (h *Handler) GetProfile(c *gin.Context) {
userID := c.GetUint("user_id")
dto, err := h.svc.GetProfile(c.Request.Context(), userID)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// Ping godoc
// @Summary 健康探测
// @Description 返回 pong,用于验证认证和多租户中间件是否正常
// @Tags System
// @Produce json
// @Success 200 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /ping [get]
func (h *Handler) Ping(c *gin.Context) {
response.Success(c, gin.H{"message": "pong"})
}

View File

@ -0,0 +1,83 @@
package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/config"
"mengstack/internal/modules/auth/application"
"mengstack/internal/modules/auth/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"gorm.io/gorm"
)
type AuthMiddleware struct {
fx.In
Service *application.Service
}
func NewAuthMiddleware(in AuthMiddleware) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(401, gin.H{"code": -1, "message": "missing authorization"})
return
}
token := authHeader
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token = authHeader[7:]
}
claims, err := application.ParseToken(token, in.Service.JWTSecret())
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"code": -1, "message": "invalid token"})
return
}
c.Set("user_id", claims.UserID)
c.Set("tenant_id", claims.TenantID)
c.Next()
}
}
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
auth := r.Group("/api/v1/auth")
auth.Use(middleware.OptionalTenant())
{
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
auth.POST("/refresh", h.RefreshToken)
}
protected := r.Group("/api/v1")
protected.Use(authMW, middleware.MultiTenant())
{
protected.GET("/ping", h.Ping)
protected.GET("/profile", h.GetProfile)
protected.POST("/password", h.ChangePassword)
}
}
func NewJWTConfig(cfg *config.Config) application.JWTConfig {
return application.JWTConfig{
Secret: cfg.JWT.Secret,
AccessExpiryMinutes: cfg.JWT.AccessExpiryMinutes,
RefreshExpiryDays: cfg.JWT.RefreshExpiryDays,
Issuer: cfg.JWT.Issuer,
}
}
var Module = fx.Module("auth",
fx.Provide(
NewJWTConfig,
infrastructure.NewUserRepository,
application.NewService,
NewHandler,
NewAuthMiddleware,
),
fx.Invoke(func(db *gorm.DB) error {
return infrastructure.Migrate(db)
}),
)

View File

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants;

View File

@ -0,0 +1,37 @@
-- Initial schema: tenants + users
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL UNIQUE,
status INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_tenants_deleted_at ON tenants(deleted_at);
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL,
tenant_id UUID NOT NULL,
username VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
password VARCHAR(256) NOT NULL,
nickname VARCHAR(64) NOT NULL DEFAULT '',
avatar VARCHAR(512) NOT NULL DEFAULT '',
status INT NOT NULL DEFAULT 1,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
PRIMARY KEY (id, tenant_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_email ON users(tenant_id, email);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
-- Seed: default tenant
INSERT INTO tenants (id, name, slug, status)
VALUES ('00000000-0000-0000-0000-000000000001', 'Default Tenant', 'default', 1)
ON CONFLICT (id) DO NOTHING;

0
test/.gitkeep Normal file
View File