commit fbf9af32f0bf470ad2a6042d610a034f7bccf951 Author: MengStack Dev Date: Sat Oct 3 00:14:40 2026 +0800 Initial commit: MengStack Go API framework Clean public release with: - Go/Gin/PostgreSQL/Redis architecture - Multi-tenant support - JWT authentication - Docker Compose deployment - Environment-based configuration diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27ff154 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aafb398 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +build/ +bin/ + +# Build output +/dist/ + +# Environment - NEVER commit real credentials +.env +.env.local +.env.*.local + +# Config files with real credentials +configs/config.local.yaml +configs/config.*.local.yaml + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Uploads +/uploads/* +!/uploads/.gitkeep + +# Logs +*.log + +# Test +coverage.out +coverage.html + +# Swagger generated (regenerate with make swagger) +# docs/docs.go +# docs/swagger.json +# docs/swagger.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..719d17c --- /dev/null +++ b/Makefile @@ -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 diff --git a/assets/logo/apple-touch-icon.png b/assets/logo/apple-touch-icon.png new file mode 100644 index 0000000..f6cc571 Binary files /dev/null and b/assets/logo/apple-touch-icon.png differ diff --git a/assets/logo/favicon-16.png b/assets/logo/favicon-16.png new file mode 100644 index 0000000..e363cc5 Binary files /dev/null and b/assets/logo/favicon-16.png differ diff --git a/assets/logo/favicon-32.png b/assets/logo/favicon-32.png new file mode 100644 index 0000000..6dc33e6 Binary files /dev/null and b/assets/logo/favicon-32.png differ diff --git a/assets/logo/favicon.ico b/assets/logo/favicon.ico new file mode 100644 index 0000000..ac28cc9 Binary files /dev/null and b/assets/logo/favicon.ico differ diff --git a/assets/logo/horizontal-source-cropped.png b/assets/logo/horizontal-source-cropped.png new file mode 100644 index 0000000..af50325 Binary files /dev/null and b/assets/logo/horizontal-source-cropped.png differ diff --git a/assets/logo/icon-source-cropped.png b/assets/logo/icon-source-cropped.png new file mode 100644 index 0000000..f40b23c Binary files /dev/null and b/assets/logo/icon-source-cropped.png differ diff --git a/assets/logo/logo-hero-200.png b/assets/logo/logo-hero-200.png new file mode 100644 index 0000000..8e9621e Binary files /dev/null and b/assets/logo/logo-hero-200.png differ diff --git a/assets/logo/logo-hero-300.png b/assets/logo/logo-hero-300.png new file mode 100644 index 0000000..1455745 Binary files /dev/null and b/assets/logo/logo-hero-300.png differ diff --git a/assets/logo/logo-icon-128.png b/assets/logo/logo-icon-128.png new file mode 100644 index 0000000..cd35c99 Binary files /dev/null and b/assets/logo/logo-icon-128.png differ diff --git a/assets/logo/logo-icon-48.png b/assets/logo/logo-icon-48.png new file mode 100644 index 0000000..b662bac Binary files /dev/null and b/assets/logo/logo-icon-48.png differ diff --git a/assets/logo/logo-icon-512.png b/assets/logo/logo-icon-512.png new file mode 100644 index 0000000..7a4af58 Binary files /dev/null and b/assets/logo/logo-icon-512.png differ diff --git a/assets/logo/logo-icon-64.png b/assets/logo/logo-icon-64.png new file mode 100644 index 0000000..72f24ac Binary files /dev/null and b/assets/logo/logo-icon-64.png differ diff --git a/assets/logo/logo-icon-dark-512.png b/assets/logo/logo-icon-dark-512.png new file mode 100644 index 0000000..0c33fa3 Binary files /dev/null and b/assets/logo/logo-icon-dark-512.png differ diff --git a/assets/logo/logo-icon-transparent-512.png b/assets/logo/logo-icon-transparent-512.png new file mode 100644 index 0000000..937c515 Binary files /dev/null and b/assets/logo/logo-icon-transparent-512.png differ diff --git a/assets/logo/logo-login-280w.png b/assets/logo/logo-login-280w.png new file mode 100644 index 0000000..82b419a Binary files /dev/null and b/assets/logo/logo-login-280w.png differ diff --git a/assets/logo/logo-nav-32h.png b/assets/logo/logo-nav-32h.png new file mode 100644 index 0000000..6b3cd2f Binary files /dev/null and b/assets/logo/logo-nav-32h.png differ diff --git a/assets/logo/logo-nav-dark-32h.png b/assets/logo/logo-nav-dark-32h.png new file mode 100644 index 0000000..81e7736 Binary files /dev/null and b/assets/logo/logo-nav-dark-32h.png differ diff --git a/assets/logo/logo-nav-light-32h.png b/assets/logo/logo-nav-light-32h.png new file mode 100644 index 0000000..cb4e417 Binary files /dev/null and b/assets/logo/logo-nav-light-32h.png differ diff --git a/assets/logo/logo-readme-1000w.png b/assets/logo/logo-readme-1000w.png new file mode 100644 index 0000000..a380952 Binary files /dev/null and b/assets/logo/logo-readme-1000w.png differ diff --git a/assets/logo/logo-readme-600w.png b/assets/logo/logo-readme-600w.png new file mode 100644 index 0000000..379f868 Binary files /dev/null and b/assets/logo/logo-readme-600w.png differ diff --git a/assets/logo/logo-sidebar-40h.png b/assets/logo/logo-sidebar-40h.png new file mode 100644 index 0000000..5d4b99a Binary files /dev/null and b/assets/logo/logo-sidebar-40h.png differ diff --git a/assets/logo/og-image-1200x630.png b/assets/logo/og-image-1200x630.png new file mode 100644 index 0000000..e76ccdd Binary files /dev/null and b/assets/logo/og-image-1200x630.png differ diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ee4098c --- /dev/null +++ b/cmd/server/main.go @@ -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 +// +// @securityDefinitions.apiKey TenantID +// @in header +// @name X-Tenant-ID +// @description 租户 ID(受保护接口必填) +package main + +import ( + "mengstack/internal/app" + + _ "mengstack/docs" +) + +func main() { + app.NewApp().Run() +} diff --git a/configs/config.dev.yaml b/configs/config.dev.yaml new file mode 100644 index 0000000..d29a528 --- /dev/null +++ b/configs/config.dev.yaml @@ -0,0 +1,6 @@ +server: + mode: debug + +log: + level: debug + format: console diff --git a/configs/config.prod.yaml b/configs/config.prod.yaml new file mode 100644 index 0000000..46f7e1d --- /dev/null +++ b/configs/config.prod.yaml @@ -0,0 +1,6 @@ +server: + mode: release + +log: + level: info + format: json diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..96c4461 --- /dev/null +++ b/configs/config.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..edecb0b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${DB_NAME:-mengstack} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-mengstack_dev} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "${SERVER_PORT:-2222}:2222" + environment: + MENGSTACK_SERVER_PORT: ${SERVER_PORT:-2222} + MENGSTACK_SERVER_MODE: ${SERVER_MODE:-debug} + MENGSTACK_DATABASE_HOST: postgres + MENGSTACK_DATABASE_PORT: 5432 + MENGSTACK_DATABASE_USER: ${DB_USER:-postgres} + MENGSTACK_DATABASE_PASSWORD: ${DB_PASSWORD:-mengstack_dev} + MENGSTACK_DATABASE_DBNAME: ${DB_NAME:-mengstack} + MENGSTACK_DATABASE_SSLMODE: disable + MENGSTACK_REDIS_ADDR: redis:6379 + MENGSTACK_REDIS_PASSWORD: ${REDIS_PASSWORD:-} + MENGSTACK_REDIS_DB: ${REDIS_DB:-0} + MENGSTACK_JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + MENGSTACK_JWT_ACCESS_EXPIRY_MINUTES: 30 + MENGSTACK_JWT_REFRESH_EXPIRY_DAYS: 7 + MENGSTACK_JWT_ISSUER: mengstack + MENGSTACK_LOG_LEVEL: ${LOG_LEVEL:-debug} + MENGSTACK_LOG_FORMAT: ${LOG_FORMAT:-console} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + pgdata: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..22e550f --- /dev/null +++ b/docs/docs.go @@ -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) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..b3fd63f --- /dev/null +++ b/docs/swagger.json @@ -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" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..10a5447 --- /dev/null +++ b/docs/swagger.yaml @@ -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 + in: header + name: Authorization + type: apiKey + TenantID: + description: 租户 ID(受保护接口必填) + in: header + name: X-Tenant-ID + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..048d12a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ddb04d4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..7a47a17 --- /dev/null +++ b/internal/app/app.go @@ -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, + ) +} diff --git a/internal/app/cache/module.go b/internal/app/cache/module.go new file mode 100644 index 0000000..34403b1 --- /dev/null +++ b/internal/app/cache/module.go @@ -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) + }), +) diff --git a/internal/app/cache/redis.go b/internal/app/cache/redis.go new file mode 100644 index 0000000..d8b75ff --- /dev/null +++ b/internal/app/cache/redis.go @@ -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 +} diff --git a/internal/app/database/module.go b/internal/app/database/module.go new file mode 100644 index 0000000..4d7b596 --- /dev/null +++ b/internal/app/database/module.go @@ -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) + }), +) diff --git a/internal/app/database/postgres.go b/internal/app/database/postgres.go new file mode 100644 index 0000000..1c95b1b --- /dev/null +++ b/internal/app/database/postgres.go @@ -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 +} diff --git a/internal/app/health/handler.go b/internal/app/health/handler.go new file mode 100644 index 0000000..d40f834 --- /dev/null +++ b/internal/app/health/handler.go @@ -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"), + }) +} diff --git a/internal/app/middleware/requestid.go b/internal/app/middleware/requestid.go new file mode 100644 index 0000000..ff9396a --- /dev/null +++ b/internal/app/middleware/requestid.go @@ -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", + )) + } +} diff --git a/internal/app/middleware/tenant.go b/internal/app/middleware/tenant.go new file mode 100644 index 0000000..fdec1dd --- /dev/null +++ b/internal/app/middleware/tenant.go @@ -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() + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4060859 --- /dev/null +++ b/internal/config/config.go @@ -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") +} diff --git a/internal/kernel/errors/errors.go b/internal/kernel/errors/errors.go new file mode 100644 index 0000000..dc5abc1 --- /dev/null +++ b/internal/kernel/errors/errors.go @@ -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) +) diff --git a/internal/kernel/model/base.go b/internal/kernel/model/base.go new file mode 100644 index 0000000..1f6916a --- /dev/null +++ b/internal/kernel/model/base.go @@ -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() +} diff --git a/internal/kernel/response/response.go b/internal/kernel/response/response.go new file mode 100644 index 0000000..6d92987 --- /dev/null +++ b/internal/kernel/response/response.go @@ -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) +} diff --git a/internal/kernel/tenant/context.go b/internal/kernel/tenant/context.go new file mode 100644 index 0000000..c4eb8e1 --- /dev/null +++ b/internal/kernel/tenant/context.go @@ -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 +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..9e2fed5 --- /dev/null +++ b/internal/logger/logger.go @@ -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 +} diff --git a/internal/modules/auth/application/jwt.go b/internal/modules/auth/application/jwt.go new file mode 100644 index 0000000..6d079a1 --- /dev/null +++ b/internal/modules/auth/application/jwt.go @@ -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 +} diff --git a/internal/modules/auth/application/service.go b/internal/modules/auth/application/service.go new file mode 100644 index 0000000..4b51e15 --- /dev/null +++ b/internal/modules/auth/application/service.go @@ -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 +} diff --git a/internal/modules/auth/domain/dto.go b/internal/modules/auth/domain/dto.go new file mode 100644 index 0000000..ca5a405 --- /dev/null +++ b/internal/modules/auth/domain/dto.go @@ -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, + } +} diff --git a/internal/modules/auth/domain/repository.go b/internal/modules/auth/domain/repository.go new file mode 100644 index 0000000..aec1271 --- /dev/null +++ b/internal/modules/auth/domain/repository.go @@ -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 +} diff --git a/internal/modules/auth/domain/user.go b/internal/modules/auth/domain/user.go new file mode 100644 index 0000000..9873e39 --- /dev/null +++ b/internal/modules/auth/domain/user.go @@ -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" +} diff --git a/internal/modules/auth/infrastructure/migrate.go b/internal/modules/auth/infrastructure/migrate.go new file mode 100644 index 0000000..6f1043e --- /dev/null +++ b/internal/modules/auth/infrastructure/migrate.go @@ -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{}) +} diff --git a/internal/modules/auth/infrastructure/user_repo.go b/internal/modules/auth/infrastructure/user_repo.go new file mode 100644 index 0000000..58bd992 --- /dev/null +++ b/internal/modules/auth/infrastructure/user_repo.go @@ -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 +} diff --git a/internal/modules/auth/interfaces/handler.go b/internal/modules/auth/interfaces/handler.go new file mode 100644 index 0000000..a9e80f7 --- /dev/null +++ b/internal/modules/auth/interfaces/handler.go @@ -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"}) +} diff --git a/internal/modules/auth/interfaces/routes.go b/internal/modules/auth/interfaces/routes.go new file mode 100644 index 0000000..c858a05 --- /dev/null +++ b/internal/modules/auth/interfaces/routes.go @@ -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) + }), +) diff --git a/migrations/000001_init_schema.down.sql b/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..c79f0a0 --- /dev/null +++ b/migrations/000001_init_schema.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS tenants; diff --git a/migrations/000001_init_schema.up.sql b/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..7c86b20 --- /dev/null +++ b/migrations/000001_init_schema.up.sql @@ -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; diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000..e69de29