feat: user CRUD API + dashboard stats + kernel infrastructure
Some checks failed
CI / Build & Test (push) Failing after 1m31s

- Add user management endpoints (list/create/get/update/delete) with pagination and search
- Add dashboard stats endpoint with tenant/user/online counts and growth metrics
- Add tenant resolver middleware for multi-tenant request scoping
- Add i18n kernel with zh/en message files and AcceptLanguage middleware
- Add WebSocket hub/handler for real-time communication
- Add job scheduler kernel with cron support
- Add plugin sandbox for isolated execution
- Add storage kernel (local filesystem)
- Add event bus kernel for pub/sub
- Add cache kernel abstraction
- Add database migration runner and version upgrade checker
- Add rate limiting middleware with Redis backend
- Add SQL migrations for rbac, audit_logs, settings, notifications, examples
- Extend user repository with list/delete/count operations
- Register all module routes with tenant resolver
This commit is contained in:
MengStack Dev 2026-10-03 03:42:58 +08:00
parent 70a5e3ffaf
commit df809f1045
79 changed files with 6177 additions and 1202 deletions

146
AGENTS.md Normal file
View File

@ -0,0 +1,146 @@
# AGENTS.md — MengStack 软盟开发框架 AI 规范
> 本文件是 AI 助手参与 MengStack 开发的最高约束。违反即返工。
## 技术栈(锁定,10 年不换)
| 层 | 选型 |
|---|---|
| 语言 | Go 1.23+ |
| Web | Gin |
| ORM | GORM v2 |
| 数据库 | PostgreSQL 16 |
| 缓存/会话 | Redis 7 |
| 依赖注入 | Uber fx |
| 配置 | Viper(YAML + 环境变量) |
| 日志 | Zap(结构化 JSON) |
| 认证 | JWT + bcrypt(cost≥12) |
| 迁移 | golang-migrate |
| API 文档 | Swag 注解 |
| 容器 | Docker + Docker Compose |
## 目录结构(三层隔离)
```
mengstack-api/
├── cmd/ # 入口:server, migrate
├── internal/
│ ├── kernel/ # L0 内核(开源,零业务语义)
│ │ ├── cache/ # 缓存接口
│ │ ├── errors/ # 统一错误码
│ │ ├── eventbus/ # 事件总线
│ │ ├── i18n/ # 国际化
│ │ ├── jobs/ # 定时任务
│ │ ├── model/ # 基础模型(BaseEntity)
│ │ ├── plugin/ # 插件接口 + 注册中心
│ │ ├── response/ # 统一响应
│ │ ├── storage/ # 存储抽象
│ │ ├── tenant/ # 多租户(Resolver/Scope/Context)
│ │ ├── testutil/ # 测试工具
│ │ └── websocket/ # WebSocket Hub
│ ├── modules/ # 业务模块(4 层分层)
│ │ ├── auth/ # 认证
│ │ ├── rbac/ # 权限
│ │ ├── org/ # 组织
│ │ ├── audit/ # 审计
│ │ ├── settings/ # 配置
│ │ ├── notification/ # 通知
│ │ └── example/ # 示例插件
│ ├── app/ # 应用层(启动/中间件/生命周期)
│ ├── config/ # 配置加载
│ └── logger/ # 日志初始化
├── migrations/ # 核心数据库迁移
├── pkg/query/ # 公共查询工具
├── configs/ # 配置文件(yaml)
└── tests/ # 集成测试 + 基准测试
```
## 四层分层(每个业务模块必须遵循)
```
domain/ → 实体 + 领域接口。禁止 import gorm/http/redis
application/ → Service 编排。不直接操作数据库
infrastructure/ → Repo 实现(GORM/Redis)。不含业务逻辑
interfaces/ → Handler + Routes。参数校验,调 Service
```
## 十条铁律(违反即返工)
1. **内核零业务语义** — kernel/ 不得出现 article/order/goods 等词
2. **依赖只向内** — domain 层零外部依赖,禁止 import gorm/http/redis
3. **跨模块走接口** — 只调对方 Service 接口,禁止 import 对方 Repo
4. **依赖方向单向** — modules → app → kernel,禁止反向
5. **tenantID 显式传递** — 作为方法参数逐层传递,禁止 context 隐式透传
6. **统一响应格式** — `{code, message, data, trace_id}`,5xx 只返"内部错误"
7. **迁移脚本** — 所有表结构变更走 golang-migrate,禁止 AutoMigrate
8. **参数化查询** — 禁止 SELECT *、禁止字符串拼接 SQL
9. **SEO 服务端直出** — 需搜索引擎收录的页面禁止客户端 JS 注入
10. **品牌统一** — 英文 MengStack,中文 软盟开发框架
## 多租户规则
- 所有业务表必须含 `tenant_id` 字段
- 查询必须带租户条件(使用 `tenant.Scope(tenantID)` GORM scope)
- Redis Key 格式:`{tenantID}:{module}:{key}`
- 文件存储路径:`{tenantID}/{YYYY/MM/DD}/{random}{ext}`
- 单租户模式:Resolver 返回固定 defaultID,系统照常运行
## 公共字段规范
所有业务实体继承 `model.BaseEntity`:
```go
ID string // UUID
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt // 软删除
TenantID string // 租户标识
```
## 错误处理三层转换
```
infrastructure → sql.ErrNoRows 翻译为 errors.ErrNotFound
application → fmt.Errorf("...: %w", err) 保留错误链
interfaces → 映射 HTTP 状态码 + 业务错误码,原文只进日志
```
## 禁止清单
- 禁止内核 import 任何 modules/ 代码
- 禁止明文存储密码(bcrypt cost≥12)
- 禁止 context 隐式传递 tenantID
- 禁止生产环境 AutoMigrate
- 禁止 SELECT * 或字符串拼接 SQL
- 禁止硬编码密钥/配置(一律环境变量)
- 禁止日志打印密码/token/密钥
- 禁止浮点存储金额(用最小单位整数)
- 禁止非 UTC 时间存储
## 插件开发规范
新业务模块作为插件开发,遵循:
1. 在 `internal/modules/<name>/` 下创建 4 层目录
2. 实现 `plugin.Plugin` 接口(Metadata/FxOption/SetupRoutes/Init/MigrationsFS)
3. 数据库迁移放在插件自己的 migrations FS 中
4. 在 `app.go` 注册插件模块
5. 插件代码不得 import kernel 以外的内部模块
## 构建与测试
```bash
GOPROXY=https://goproxy.cn,direct go build ./... # 编译
go test ./... # 测试
go vet ./... # 静态检查
```
## 文档索引
| 文档 | 位置 |
|------|------|
| 项目说明书 v1.5 | mengstack-docs/ |
| 开发标准 v2.5 | mengstack-docs/ |
| API 文档(Swagger) | /swagger/index.html |
| 官网 | VitePress 站点 |
| 开发者文档 | VitePress 文档站 |

200
LICENSE Normal file
View File

@ -0,0 +1,200 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any party (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to separate files that you distribute,
include the following boilerplate notice, with the fields enclosed
by brackets "[]" replaced with your own identifying information.
(Don't include the brackets!) The text should be enclosed in the
appropriate comment syntax for the file format. Please also get an
in-depth understanding of this license by reading the FAQ at
<http://www.apache.org/foundation/license-faq.html>.
Copyright 2026 SoftUnis (软盟)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

349
README.md
View File

@ -1,44 +1,20 @@
# MengStack API
# MengStack Core
MengStack 是一个基于 Go 的企业级后端框架,采用清晰的分层架构和依赖注入,帮助开发者快速构建高质量的多租户 Web 应用。
[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?logo=go)](https://go.dev)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16+-336791?logo=postgresql)](https://www.postgresql.org)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D?logo=redis)](https://redis.io)
[![License](https://img.shields.io/badge/License-Apache--2.0-blue)](LICENSE)
## 技术栈
MengStack(软盟开发框架)是一个面向多租户 SaaS 应用的 Go 通用底座。把账号、权限、租户、组织、审计、配置、消息、插件等所有 Web 应用共享的能力一次性做好,让你只专注于业务逻辑。
| 组件 | 技术选型 | 说明 |
|------|---------|------|
| Web 框架 | [Gin](https://github.com/gin-gonic/gin) | 高性能 HTTP 路由 |
| ORM | [GORM](https://gorm.io) | PostgreSQL 数据访问 |
| 缓存 | [go-redis](https://github.com/redis/go-redis) | Redis 客户端 |
| 依赖注入 | [uber/fx](https://github.com/uber-go/fx) | 生命周期管理 |
| 配置管理 | [Viper](https://github.com/spf13/viper) | 分层配置 + 环境变量 |
| 日志 | [Zap](https://github.com/uber-go/zap) | 结构化高性能日志 |
| 认证 | JWT | Access Token + Refresh Token |
## 为什么选择 MengStack
## 架构设计
### 三层隔离
```
cmd/server/ → 启动层:组装依赖,启动服务
internal/app/ → 基础设施层:数据库、缓存、中间件、健康检查
internal/modules/ → 业务模块层:按领域划分的独立模块
```
### 模块四层结构
每个业务模块遵循统一的四层架构:
```
modules/auth/
├── domain/ → 领域层:实体、DTO、仓储接口
├── application/ → 应用层:业务逻辑、服务实现
├── infrastructure/ → 基础设施层:仓储实现、数据库迁移
└── interfaces/ → 接口层:HTTP Handler、路由注册
```
### 依赖注入
使用 `uber/fx` 管理依赖,每个模块通过 `fx.Provide` 注册,框架自动解析依赖图。生命周期钩子(`fx.Lifecycle`)管理服务器启停,禁止在 Provider 中启动 goroutine。
- **多租户内建** — 共享表 + `tenant_id` 显式过滤,可选 Schema 隔离与 PG 行级安全(RLS)兜底
- **插件架构** — 通过扩展点和 `.mengplugin` 包格式扩展功能,不改动内核代码
- **四层分层** — domain → application → infrastructure → interfaces,职责清晰、可测试
- **编译期依赖注入** — 基于 Uber fx,依赖错误在编译时暴露,不是运行时
- **安全默认** — JWT 双 Token、RBAC 权限、bcrypt 密码、参数化查询、XSS 过滤、三级限流
- **可观测性** — Zap 结构化日志(含 trace_id / tenant_id)、健康检查、就绪探针
## 快速开始
@ -48,161 +24,232 @@ modules/auth/
- PostgreSQL 16+
- Redis 7+
### 方式一:Docker Compose(推荐)
### 1. 克隆项目
```bash
# 克隆项目
git clone https://mengstackgit.softunis.com/softunis/mengstack-api.git
cd mengstack-api
# 复制环境变量并修改
cp .env.example .env
# 一键启动(PostgreSQL + Redis + API)
docker-compose up -d
# 查看日志
docker-compose logs -f api
git clone https://github.com/mengstack/core.git
cd core
```
### 方式二:本地开发
### 2. 配置环境
```bash
# 1. 启动 PostgreSQL 和 Redis(可用 Docker)
docker-compose up -d postgres redis
# 2. 复制并编辑配置
cp .env.example .env
# 编辑 .env 填入数据库密码等
# 编辑 .env,填入数据库密码和 JWT 密钥
```
# 3. 安装依赖
### 3. 启动依赖服务
```bash
docker-compose up -d postgres redis
```
### 4. 启动服务
```bash
# 安装依赖
go mod download
# 4. 启动服务
# 运行迁移 + 启动
make run
# 或
go run ./cmd/server
```
服务启动后访问 `http://localhost:2222/health` 验证。
服务启动后:
| 端点 | 地址 |
|------|------|
| API | `http://localhost:2222` |
| 健康检查 | `http://localhost:2222/health` |
| 就绪探针 | `http://localhost:2222/readyz` |
| Swagger 文档 | `http://localhost:2222/swagger/index.html` |
### 或者用 Docker 一键启动
```bash
cp .env.example .env
docker-compose up -d
docker-compose logs -f api
```
## 项目结构
```
mengstack-api/
├── cmd/server/ # 应用入口
├── configs/ # 配置模板
│ ├── config.yaml # 基础配置
│ ├── config.dev.yaml # 开发环境覆盖
│ └── config.prod.yaml # 生产环境覆盖
.
├── cmd/
│ ├── server/ # 主服务入口
│ ├── migrate/ # 数据库迁移工具
│ └── smrm/ # CLI 脚手架工具
├── internal/
│ ├── app/ # 基础设施
│ │ ├── cache/ # Redis 模块
│ │ ├── database/ # PostgreSQL 模块
│ │ ├── health/ # 健康检查
│ │ └── middleware/ # 请求ID、多租户中间件
│ ├── config/ # 配置加载器
│ ├── kernel/ # 核心工具(错误、响应、租户上下文)
│ ├── logger/ # Zap 日志
│ ├── app/ # 应用基础设施
│ │ ├── cache/ # Redis 连接
│ │ ├── database/ # PostgreSQL 连接
│ │ ├── health/ # 健康检查 & 就绪探针
│ │ ├── middleware/ # 请求 ID、认证、多租户、i18n
│ │ ├── migrate/ # 迁移执行器
│ │ └── upgrade/ # 在线升级预留
│ ├── config/ # Viper 配置加载
│ ├── kernel/ # ★ 内核(开源部分)
│ │ ├── errors/ # 统一错误码
│ │ ├── response/ # 统一响应格式
│ │ ├── tenant/ # 多租户抽象
│ │ ├── model/ # 公共基础实体
│ │ ├── plugin/ # 插件接口 & 沙箱
│ │ ├── eventbus/ # 事件总线
│ │ ├── storage/ # 存储抽象
│ │ ├── cache/ # 缓存工具
│ │ ├── jobs/ # 后台任务调度
│ │ ├── i18n/ # 国际化框架
│ │ └── websocket/ # WebSocket 推送
│ ├── logger/ # Zap 结构化日志
│ └── modules/ # 业务模块
│ └── auth/ # 认证模块(注册/登录/刷新Token)
├── migrations/ # 数据库迁移脚本
├── docs/ # Swagger 文档
├── assets/ # 静态资源(Logo 等)
├── .env.example # 环境变量模板
├── docker-compose.yml # 容器编排
├── Makefile # 常用命令
└── go.mod # Go 模块定义
│ ├── auth/ # 认证(注册/登录/刷新)
│ ├── rbac/ # 角色权限
│ ├── org/ # 组织架构
│ ├── audit/ # 审计日志
│ ├── settings/ # 配置管理
│ ├── notification/# 消息通知
│ └── example/ # 示例插件
├── migrations/ # 核心数据库迁移脚本
├── configs/ # 分层配置模板
├── docs/ # Swagger 生成文档
├── test/ # 测试工具
├── AGENTS.md # AI 辅助开发规范
└── Makefile # 常用命令
```
## 配置系统
## 核心能力
MengStack 采用三层配置覆盖机制,优先级从低到高:
### 多租户
1. **基础配置** `configs/config.yaml` — 所有环境的默认值
2. **环境覆盖** `configs/config.{mode}.yaml` — 按 `server.mode` 加载(dev/prod)
3. **环境变量** `MENGSTACK_` 前缀 — 最高优先级,适合存放密码等敏感信息
```go
// 请求头传入租户 ID
// X-Tenant-ID: tenant-abc-123
// 框架自动解析并注入上下文
tenantID := tenant.FromContext(c.Request.Context())
```
支持三种隔离策略,通过配置切换:
- **共享表 + tenant_id**(默认,适合大多数场景)
- **Schema 隔离**(高安全需求)
- **独立数据库**(企业级隔离)
### 插件系统
每个插件是一个独立的模块,声明所需权限,运行时由沙箱强制执行:
```go
func (p *MyPlugin) Permissions() plugin.Permissions {
return plugin.Permissions{
Database: []string{"my_table"}, // 只能访问声明的表
Events: []string{"order.created"}, // 只能发射声明的事件
Routes: []string{"/api/v1/orders/*"},
}
}
```
插件 panic 不会影响主进程 — 沙箱通过 `recover` 隔离故障。
创建新插件:
```bash
# 环境变量命名规则:MENGSTACK_{Section}_{Key}
# 例如:
MENGSTACK_SERVER_PORT=8080
MENGSTACK_DATABASE_PASSWORD=your-secret
MENGSTACK_JWT_SECRET=your-jwt-secret
go run ./cmd/smrm new module order
```
**安全提示**:永远不要将真实密码提交到 git。使用 `.env` 文件(已加入 `.gitignore`)或系统环境变量。
### 统一响应
```json
{
"code": 0,
"message": "success",
"data": { ... },
"trace_id": "abc-123"
}
```
错误码体系:业务错误返回具体码 + 国际化消息,5xx 统一返回"服务器内部错误",原文只进日志。
### 配置分层
优先级从低到高:
1. `configs/config.yaml` — 基础默认值
2. `configs/config.{mode}.yaml` — 环境覆盖(dev / prod)
3. `MENGSTACK_` 环境变量 — 最高优先级
```bash
MENGSTACK_DATABASE_PASSWORD=secret
MENGSTACK_JWT_SECRET=your-jwt-key
```
## 开发规范
### 四层分层
| 层 | 文件 | 职责 | 禁止 |
|----|------|------|------|
| Domain | `model.go` | 实体、领域接口 | import gorm/http/redis |
| Application | `service.go` | 用例编排、事务 | 直接操作数据库 |
| Infrastructure | `repo.go` | 数据库/缓存实现 | 含业务逻辑 |
| Interfaces | `handler.go` | 参数校验、调 service | 写业务逻辑 |
### 十条铁律
1. 内核绝不装业务语义(无 article / order / goods)
2. 依赖只能由外向内(domain 层零外部依赖)
3. 跨模块只走接口(禁止直接 import 其他模块的 repo)
4. 依赖方向单调:modules → kernel,禁止反向
5. tenantID 显式传递(禁止 context 隐式透传)
6. 统一响应格式与错误码
7. 所有数据库变更走迁移脚本
8. 禁止 SELECT *、禁止字符串拼接 SQL
9. 品牌写法统一:MengStack + 软盟开发框架
10. 插件只能通过扩展点接入,内核不依赖任何插件
### 规范自检
```bash
# 检查内核纯净性(禁止业务语义泄漏)
go run ./cmd/smrm check
```
## 常用命令
```bash
make build # 编译二进制
make build # 编译
make run # 启动开发服务
make test # 运行测试
make test-cover # 测试覆盖率报告
make test-cover # 测试覆盖率
make lint # 代码检查
make swagger # 重新生成 Swagger 文档
make migrate # 运行数据库迁移
make docker-up # Docker 启动全部服务
make docker-down # Docker 停止所有服务
make clean # 清理构建产物
```
## API 概览
### 认证接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/auth/register` | 用户注册 |
| POST | `/api/v1/auth/login` | 用户登录 |
| POST | `/api/v1/auth/refresh` | 刷新 Token |
| GET | `/api/v1/auth/me` | 获取当前用户(需认证) |
| GET | `/health` | 健康检查 |
### 多租户
所有业务接口通过 `X-Tenant-ID` 请求头标识租户。框架自动解析租户上下文并注入到数据库查询中。
完整 API 文档:启动服务后访问 `/swagger/index.html`。
## 添加新模块
以创建 `article` 模块为例:
```
internal/modules/article/
├── domain/
│ ├── entity.go # Article 实体
│ ├── dto.go # 请求/响应 DTO
│ └── repository.go # 仓储接口
├── application/
│ └── service.go # 业务逻辑实现
├── infrastructure/
│ ├── repository.go # GORM 仓储实现
│ └── migrate.go # AutoMigrate 注册
└── interfaces/
├── handler.go # HTTP Handler
└── routes.go # 路由注册(实现 RouteGroup 接口)
```
1. 创建上述目录和文件
2. 在 `internal/app/app.go` 中添加 `fx.Provide` 注册新模块
3. 在 `infrastructure/migrate.go` 中注册 AutoMigrate
4. 框架自动发现并注册路由
## 开发规范
- **依赖注入**:所有依赖通过 `fx.Provide` 注册,使用 `fx.In` 结构体注入
- **生命周期**:服务器启停使用 `fx.Lifecycle` 的 `OnStart/OnStop` 钩子
- **错误处理**:使用 `kernel/errors` 统一错误码
- **响应格式**:使用 `kernel/response` 统一 JSON 响应结构
- **日志**:通过 `logger.L()` 获取全局 Zap 实例
## 演示站
- API 地址:https://mengstackdemo.softunis.com
- Swagger 文档:https://mengstackdemo.softunis.com/swagger/index.html
- API:https://mengstackdemo.softunis.com
- Swagger:https://mengstackdemo.softunis.com/swagger/index.html
## 技术栈
| 组件 | 选型 | 说明 |
|------|------|------|
| 语言 | Go 1.23+ | 编译为单文件,部署极简 |
| Web 框架 | Gin | 高性能 HTTP 路由 |
| ORM | GORM v2 | PostgreSQL 数据访问 |
| 数据库 | PostgreSQL 16 | 多租户原生 + 许可可闭源 |
| 缓存 | Redis 7 | 会话 / 缓存 / 队列 |
| 依赖注入 | Uber fx | 编译期装配,生命周期管理 |
| 配置 | Viper | 分层配置 + 环境变量 |
| 日志 | Zap | 结构化高性能日志 |
| 认证 | JWT | Access + Refresh 双 Token |
| 迁移 | golang-migrate | 版本化数据库变更 |
| API 文档 | Swag | 注解自动生成 Swagger |
## 许可证
MIT License
[Apache License 2.0](LICENSE)
Copyright 2026 SoftUnis(软盟)

107
cmd/migrate/main.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"flag"
"fmt"
"os"
"mengstack/internal/config"
"mengstack/migrations"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: migrate <command> [flags]")
fmt.Println("Commands: up, down, status")
fmt.Println(" up [steps] Run pending migrations (optional step count)")
fmt.Println(" down [steps] Rollback migrations (optional step count, 0=all)")
fmt.Println(" status Show current migration status")
os.Exit(1)
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
dsn := fmt.Sprintf(
"postgres://%s:%s@%s:%d/%s?sslmode=%s&search_path=public",
cfg.Database.User, cfg.Database.Password,
cfg.Database.Host, cfg.Database.Port,
cfg.Database.DBName, cfg.Database.SSLMode,
)
source, err := iofs.New(migrations.Files, ".")
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating migration source: %v\n", err)
os.Exit(1)
}
m, err := migrate.NewWithSourceInstance("iofs", source, dsn)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating migrator: %v\n", err)
os.Exit(1)
}
cmd := os.Args[1]
switch cmd {
case "up":
steps := 0
fs := flag.NewFlagSet("up", flag.ExitOnError)
fs.IntVar(&steps, "steps", 0, "number of migrations to run (0=all)")
fs.Parse(os.Args[2:])
if steps > 0 {
err = m.Steps(steps)
} else {
err = m.Up()
}
if err != nil && err != migrate.ErrNoChange {
fmt.Fprintf(os.Stderr, "Migration error: %v\n", err)
os.Exit(1)
}
if err == migrate.ErrNoChange {
fmt.Println("No pending migrations")
} else {
fmt.Println("Migrations applied successfully")
}
case "down":
steps := 1
fs := flag.NewFlagSet("down", flag.ExitOnError)
fs.IntVar(&steps, "steps", 1, "number of migrations to rollback (0=all)")
fs.Parse(os.Args[2:])
if steps == 0 {
err = m.Down()
} else {
err = m.Steps(-steps)
}
if err != nil && err != migrate.ErrNoChange {
fmt.Fprintf(os.Stderr, "Rollback error: %v\n", err)
os.Exit(1)
}
fmt.Println("Rollback completed")
case "status":
v, dirty, err := m.Version()
if err != nil && err != migrate.ErrNoChange {
fmt.Fprintf(os.Stderr, "Status error: %v\n", err)
os.Exit(1)
}
if err == migrate.ErrNoChange {
fmt.Println("No migrations applied yet")
} else {
fmt.Printf("Current version: %d, dirty: %v\n", v, dirty)
}
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
os.Exit(1)
}
}

442
cmd/smrm/main.go Normal file
View File

@ -0,0 +1,442 @@
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"text/template"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "new":
if len(os.Args) < 4 || os.Args[2] != "module" {
fmt.Println("用法: smrm new module <name>")
os.Exit(1)
}
createModule(os.Args[3])
case "check":
runCheck()
default:
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println("smrm — MengStack 脚手架工具")
fmt.Println()
fmt.Println("用法:")
fmt.Println(" smrm new module <name> 创建标准四层模块骨架")
fmt.Println(" smrm check 规范自检(扫描常见违规)")
}
func createModule(name string) {
base := filepath.Join("internal", "modules", name)
dirs := []string{"domain", "application", "infrastructure", "interfaces"}
for _, d := range dirs {
path := filepath.Join(base, d)
if err := os.MkdirAll(path, 0755); err != nil {
fmt.Fprintf(os.Stderr, "创建目录失败: %v\n", err)
os.Exit(1)
}
}
files := map[string]string{
filepath.Join(base, "domain", "model.go"): modelTemplate,
filepath.Join(base, "domain", "repository.go"): repoTemplate,
filepath.Join(base, "application", "service.go"): serviceTemplate,
filepath.Join(base, "infrastructure", "repo.go"): infraRepoTemplate,
filepath.Join(base, "interfaces", "handler.go"): handlerTemplate,
filepath.Join(base, "interfaces", "plugin.go"): pluginTemplate,
}
data := templateData{Name: name, Title: strings.Title(name)}
for path, tmpl := range files {
if err := writeTemplate(path, tmpl, data); err != nil {
fmt.Fprintf(os.Stderr, "生成文件失败 %s: %v\n", path, err)
os.Exit(1)
}
}
fmt.Printf("✅ 模块 %s 已创建\n", name)
fmt.Println(" 下一步:")
fmt.Printf(" 1. 在 internal/app/app.go 注册 %sinterfaces.Module\n", name)
fmt.Printf(" 2. 在 newEngine() 中调用 %s 插件的 SetupRoutes\n", name)
fmt.Println(" 3. 实现业务逻辑")
}
type templateData struct {
Name string
Title string
}
func writeTemplate(path, tmplStr string, data templateData) error {
tmpl, err := template.New("").Parse(tmplStr)
if err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return tmpl.Execute(f, data)
}
func runCheck() {
violations := 0
fmt.Println("🔍 MengStack 规范自检")
fmt.Println("=====================")
err := filepath.Walk("internal/kernel", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return err
}
v := checkKernelImports(path)
violations += len(v)
for _, msg := range v {
fmt.Printf(" ❌ %s\n", msg)
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "扫描失败: %v\n", err)
os.Exit(1)
}
err = filepath.Walk("internal/modules", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return err
}
if strings.Contains(path, string(filepath.Separator)+"domain"+string(filepath.Separator)) {
v := checkDomainImports(path)
violations += len(v)
for _, msg := range v {
fmt.Printf(" ❌ %s\n", msg)
}
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "扫描失败: %v\n", err)
os.Exit(1)
}
if violations == 0 {
fmt.Println(" ✅ 未发现违规项")
} else {
fmt.Printf("\n 共发现 %d 处违规\n", violations)
os.Exit(1)
}
}
var forbiddenKernelImports = []string{
"mengstack/internal/modules",
"mengstack/internal/apps",
"mengstack/internal/middle",
}
var forbiddenDomainImports = []string{
"gorm.io/gorm",
"github.com/gin-gonic/gin",
"github.com/redis/go-redis",
}
func checkKernelImports(path string) []string {
return checkForbidden(path, forbiddenKernelImports, "kernel 层禁止 import 业务模块")
}
func checkDomainImports(path string) []string {
return checkForbidden(path, forbiddenDomainImports, "domain 层禁止 import 外部依赖")
}
func checkForbidden(path string, forbidden []string, reason string) []string {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return nil
}
var violations []string
for _, imp := range f.Imports {
importPath := strings.Trim(imp.Path.Value, `"`)
for _, fb := range forbidden {
if strings.HasPrefix(importPath, fb) {
violations = append(violations, fmt.Sprintf("%s — %s(%s)", path, importPath, reason))
}
}
}
return violations
}
func checkFileForAST(_ string) *ast.File {
return nil
}
var modelTemplate = `package domain
import "time"
// {{.Title}} 领域实体。
type {{.Title}} struct {
ID string ` + "`" + `json:"id"` + "`" + `
TenantID string ` + "`" + `json:"tenant_id"` + "`" + `
CreatedAt time.Time ` + "`" + `json:"created_at"` + "`" + `
UpdatedAt time.Time ` + "`" + `json:"updated_at"` + "`" + `
}
`
var repoTemplate = `package domain
import "context"
// Repository 定义 {{.Name}} 模块的数据访问接口。
type Repository interface {
Create(ctx context.Context, item *{{.Title}}) error
GetByID(ctx context.Context, tenantID, id string) (*{{.Title}}, error)
List(ctx context.Context, tenantID string) ([]{{.Title}}, error)
Delete(ctx context.Context, tenantID, id string) error
}
`
var serviceTemplate = `package application
import (
"context"
"mengstack/internal/modules/{{.Name}}/domain"
)
// Service 编排 {{.Name}} 模块的业务逻辑。
type Service struct {
repo domain.Repository
}
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, item *domain.{{.Title}}) error {
return s.repo.Create(ctx, item)
}
func (s *Service) Get(ctx context.Context, tenantID, id string) (*domain.{{.Title}}, error) {
return s.repo.GetByID(ctx, tenantID, id)
}
func (s *Service) List(ctx context.Context, tenantID string) ([]domain.{{.Title}}, error) {
return s.repo.List(ctx, tenantID)
}
func (s *Service) Delete(ctx context.Context, tenantID, id string) error {
return s.repo.Delete(ctx, tenantID, id)
}
`
var infraRepoTemplate = `package infrastructure
import (
"context"
"mengstack/internal/modules/{{.Name}}/domain"
"gorm.io/gorm"
)
type repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) domain.Repository {
return &repository{db: db}
}
func (r *repository) Create(ctx context.Context, item *domain.{{.Title}}) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *repository) GetByID(ctx context.Context, tenantID, id string) (*domain.{{.Title}}, error) {
var item domain.{{.Title}}
err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&item).Error
if err != nil {
return nil, err
}
return &item, nil
}
func (r *repository) List(ctx context.Context, tenantID string) ([]domain.{{.Title}}, error) {
var items []domain.{{.Title}}
err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&items).Error
return items, err
}
func (r *repository) Delete(ctx context.Context, tenantID, id string) error {
return r.db.WithContext(ctx).
Where("id = ? AND tenant_id = ?", id, tenantID).
Delete(&domain.{{.Title}}{}).Error
}
`
var handlerTemplate = `package interfaces
import (
"net/http"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/{{.Name}}/application"
"mengstack/internal/modules/{{.Name}}/domain"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *application.Service
}
func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Create(c *gin.Context) {
tenantID := tenant.MustFromContext(c.Request.Context())
var item domain.{{.Title}}
if err := c.ShouldBindJSON(&item); err != nil {
response.Fail(c, err)
return
}
item.TenantID = tenantID
if err := h.svc.Create(c.Request.Context(), &item); err != nil {
response.Fail(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) List(c *gin.Context) {
tenantID := tenant.MustFromContext(c.Request.Context())
items, err := h.svc.List(c.Request.Context(), tenantID)
if err != nil {
response.Fail(c, err)
return
}
response.OK(c, items)
}
func (h *Handler) Get(c *gin.Context) {
tenantID := tenant.MustFromContext(c.Request.Context())
id := c.Param("id")
item, err := h.svc.Get(c.Request.Context(), tenantID, id)
if err != nil {
response.Fail(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Delete(c *gin.Context) {
tenantID := tenant.MustFromContext(c.Request.Context())
id := c.Param("id")
if err := h.svc.Delete(c.Request.Context(), tenantID, id); err != nil {
response.Fail(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// unused guard — 确保 http 包被引用
var _ = http.StatusOK
`
var pluginTemplate = `package interfaces
import (
"io/fs"
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/plugin"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/{{.Name}}/application"
"mengstack/internal/modules/{{.Name}}/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
)
type {{.Title}}Plugin struct {
handler *Handler
}
func NewPlugin(handler *Handler) *{{.Title}}Plugin {
p := &{{.Title}}Plugin{handler: handler}
plugin.Register(p)
return p
}
func (p *{{.Title}}Plugin) Metadata() plugin.Metadata {
return plugin.Metadata{
Name: "{{.Name}}",
Version: "0.1.0",
Description: "{{.Title}} 模块",
}
}
func (p *{{.Title}}Plugin) Permissions() plugin.Permissions {
return plugin.Permissions{
Database: []string{""},
Storage: false,
Network: []string{},
Events: []string{},
Routes: []string{"/api/v1/{{.Name}}s", "/api/v1/{{.Name}}s/*"},
AdminPanel: false,
}
}
func (p *{{.Title}}Plugin) FxOption() fx.Option {
return fx.Module("{{.Name}}-plugin",
fx.Provide(
infrastructure.NewRepository,
application.NewService,
NewHandler,
),
)
}
func (p *{{.Title}}Plugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
g := engine.Group("/api/v1/{{.Name}}s", authMW, middleware.MultiTenant(tenantResolver))
g.POST("", p.handler.Create)
g.GET("", p.handler.List)
g.GET("/:id", p.handler.Get)
g.DELETE("/:id", p.handler.Delete)
}
func (p *{{.Title}}Plugin) MigrationsFS() fs.FS {
return nil
}
func (p *{{.Title}}Plugin) Init() error {
return nil
}
var Module = fx.Module("{{.Name}}-plugin",
fx.Provide(
infrastructure.NewRepository,
application.NewService,
NewHandler,
NewPlugin,
),
)
`

View File

@ -24,3 +24,7 @@ jwt:
log:
level: info
format: json
tenant:
mode: multi
default_id: "00000000-0000-0000-0000-000000000001"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

32
go.mod
View File

@ -1,33 +1,39 @@
module mengstack
go 1.23.0
go 1.25.11
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.22.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.20.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
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
golang.org/x/crypto v0.53.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/cespare/xxhash/v2 v2.3.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/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // 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
@ -38,8 +44,8 @@ require (
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
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)
@ -76,11 +82,11 @@ require (
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
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

117
go.sum
View File

@ -1,5 +1,9 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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=
@ -12,12 +16,16 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
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=
@ -25,6 +33,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
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/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
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=
@ -37,6 +57,12 @@ 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-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@ -59,25 +85,31 @@ 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/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/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/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/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=
@ -95,6 +127,8 @@ 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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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=
@ -105,19 +139,37 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
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=
@ -145,8 +197,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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=
@ -160,6 +213,16 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
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=
@ -175,24 +238,24 @@ 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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -201,8 +264,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -211,16 +274,16 @@ 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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
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=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -10,15 +10,23 @@ import (
"mengstack/internal/app/database"
"mengstack/internal/app/health"
"mengstack/internal/app/middleware"
appmigrate "mengstack/internal/app/migrate"
"mengstack/internal/app/upgrade"
"mengstack/internal/config"
"mengstack/internal/kernel/i18n"
"mengstack/internal/kernel/plugin"
"mengstack/internal/kernel/jobs"
"mengstack/internal/kernel/tenant"
kws "mengstack/internal/kernel/websocket"
"mengstack/internal/logger"
"mengstack/migrations"
authinterfaces "mengstack/internal/modules/auth/interfaces"
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
orginterfaces "mengstack/internal/modules/org/interfaces"
auditinterfaces "mengstack/internal/modules/audit/interfaces"
settingsinterfaces "mengstack/internal/modules/settings/interfaces"
notificationinterfaces "mengstack/internal/modules/notification/interfaces"
dashboardinterfaces "mengstack/internal/modules/dashboard/interfaces"
exampleinterfaces "mengstack/internal/modules/example/interfaces"
"github.com/gin-gonic/gin"
@ -32,8 +40,19 @@ import (
)
var Module = fx.Module("app",
fx.Provide(newEngine),
fx.Provide(newServer),
fx.Provide(
newEngine,
newServer,
func(log *zap.Logger) *kws.Hub {
return kws.NewHub(log)
},
func(hub *kws.Hub, log *zap.Logger) *kws.Handler {
return kws.NewHandler(hub, log)
},
func(log *zap.Logger) (*jobs.Scheduler, error) {
return jobs.NewScheduler(log)
},
),
fx.Invoke(registerLifecycle),
)
@ -49,7 +68,9 @@ func newEngine(
auditHandler *auditinterfaces.Handler,
settingsHandler *settingsinterfaces.Handler,
notificationHandler *notificationinterfaces.Handler,
dashboardHandler *dashboardinterfaces.Handler,
examplePlugin *exampleinterfaces.ExamplePlugin,
wsHandler *kws.Handler,
) *gin.Engine {
ginMode := "release"
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
@ -62,23 +83,35 @@ func newEngine(
r.Use(middleware.RequestLogger(log))
r.Use(middleware.SecurityHeaders())
r.Use(middleware.CORS())
r.Use(middleware.RateLimit(100, time.Minute))
r.Use(middleware.AcceptLanguage())
r.Use(middleware.RateLimitRedis(rdb))
r.Use(middleware.BodyLimit(10 << 20))
r.Use(middleware.Recovery(log))
healthHandler := health.NewHandler(db, rdb, log)
r.GET("/health", healthHandler.Handle)
r.GET("/healthz", healthHandler.Healthz)
r.GET("/readyz", healthHandler.Readyz)
r.GET("/api/version", healthHandler.Version)
upgradeHandler := upgrade.NewHandler()
r.GET("/api/upgrade/check", upgradeHandler.Check)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
authinterfaces.SetupRoutes(r, authHandler, authMW)
rbacinterfaces.SetupRoutes(r, rbacHandler, authMW)
orginterfaces.SetupRoutes(r, orgHandler, authMW)
auditinterfaces.SetupRoutes(r, auditHandler, authMW)
settingsinterfaces.SetupRoutes(r, settingsHandler, authMW)
notificationinterfaces.SetupRoutes(r, notificationHandler, authMW)
tenantResolver := tenant.NewResolver(cfg.Tenant)
examplePlugin.SetupRoutes(r, authMW)
authinterfaces.SetupRoutes(r, authHandler, authMW, tenantResolver)
rbacinterfaces.SetupRoutes(r, rbacHandler, authMW, tenantResolver)
orginterfaces.SetupRoutes(r, orgHandler, authMW, tenantResolver)
auditinterfaces.SetupRoutes(r, auditHandler, authMW, tenantResolver)
settingsinterfaces.SetupRoutes(r, settingsHandler, authMW, tenantResolver)
notificationinterfaces.SetupRoutes(r, notificationHandler, authMW, tenantResolver)
dashboardinterfaces.SetupRoutes(r, dashboardHandler, authMW, tenantResolver)
r.GET("/api/v1/ws", authMW, wsHandler.ServeWS)
examplePlugin.SetupRoutes(r, authMW, tenantResolver)
return r
}
@ -93,12 +126,38 @@ func newServer(cfg *config.Config, engine *gin.Engine) *http.Server {
}
}
func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
func registerLifecycle(lc fx.Lifecycle, srv *http.Server, db *gorm.DB, sched *jobs.Scheduler, log *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
if err := plugin.InitAll(log); err != nil {
if err := i18n.Init(i18n.Files, "zh"); err != nil {
return fmt.Errorf("init i18n: %w", err)
}
log.Info("i18n initialized")
if err := appmigrate.Run(db, migrations.Files); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
log.Info("database migrations applied")
for _, p := range plugin.All() {
meta := p.Metadata()
pfs := p.MigrationsFS()
if pfs == nil {
continue
}
if err := appmigrate.RunPlugin(db, meta.Name, pfs); err != nil {
return fmt.Errorf("plugin %s migrations: %w", meta.Name, err)
}
log.Info("plugin migrations applied", zap.String("plugin", meta.Name))
}
sandbox := plugin.NewSandbox(log)
if err := plugin.InitAll(log, sandbox); err != nil {
return err
}
sched.Start()
log.Info("server starting", zap.String("addr", srv.Addr))
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@ -111,6 +170,9 @@ func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Info("server shutting down")
if err := sched.Stop(); err != nil {
log.Error("scheduler stop error", zap.Error(err))
}
return srv.Shutdown(shutdownCtx)
},
})
@ -130,6 +192,7 @@ func NewApp() *fx.App {
auditinterfaces.Module,
settingsinterfaces.Module,
notificationinterfaces.Module,
dashboardinterfaces.Module,
exampleinterfaces.Module,
Module,
)

View File

@ -2,13 +2,19 @@ package cache
import (
"mengstack/internal/config"
"mengstack/internal/kernel/cache"
"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)
}),
fx.Provide(
func(cfg *config.Config) (*redis.Client, error) {
return NewRedis(cfg.Redis)
},
func(rdb *redis.Client) *cache.Store {
return cache.NewStore(rdb, "mengstack")
},
),
)

View File

@ -5,6 +5,8 @@ import (
"net/http"
"time"
"mengstack/internal/app/version"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@ -51,7 +53,71 @@ func (h *Handler) Handle(c *gin.Context) {
"database": dbStatus,
"redis": redisStatus,
"timestamp": time.Now().Unix(),
"version": "0.1.0",
"version": version.Version,
"trace_id": c.GetString("trace_id"),
})
}
// Version godoc
// @Summary 版本信息
// @Description 返回构建版本、提交哈希、编译时间等
// @Tags System
// @Produce json
// @Success 200 {object} version.Info
// @Router /api/version [get]
func (h *Handler) Version(c *gin.Context) {
c.JSON(http.StatusOK, version.Current())
}
// Healthz godoc
// @Summary 存活探针
// @Description Kubernetes liveness probe,进程存活即返回 200
// @Tags System
// @Produce json
// @Success 200 {object} object{status=string}
// @Router /healthz [get]
func (h *Handler) Healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// Readyz godoc
// @Summary 就绪探针
// @Description Kubernetes readiness probe,检查数据库和 Redis 连接
// @Tags System
// @Produce json
// @Success 200 {object} object{status=string,database=string,redis=string}
// @Failure 503 {object} object{status=string,database=string,redis=string}
// @Router /readyz [get]
func (h *Handler) Readyz(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
dbOK := true
sqlDB, err := h.db.DB()
if err != nil || sqlDB.Ping() != nil {
dbOK = false
}
redisOK := true
if h.rdb.Ping(ctx).Err() != nil {
redisOK = false
}
body := gin.H{
"database": boolToStatus(dbOK),
"redis": boolToStatus(redisOK),
}
if !dbOK || !redisOK {
c.JSON(http.StatusServiceUnavailable, body)
return
}
c.JSON(http.StatusOK, body)
}
func boolToStatus(ok bool) string {
if ok {
return "connected"
}
return "disconnected"
}

View File

@ -0,0 +1,17 @@
package middleware
import (
"mengstack/internal/kernel/i18n"
"github.com/gin-gonic/gin"
)
func AcceptLanguage() gin.HandlerFunc {
return func(c *gin.Context) {
lang := i18n.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
c.Set("lang", lang)
ctx := i18n.WithLang(c.Request.Context(), lang)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}

View File

@ -0,0 +1,111 @@
package middleware
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"mengstack/internal/kernel/errors"
"mengstack/internal/kernel/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
rateLimitHeader = "X-RateLimit-Limit"
rateLimitRemainHeader = "X-RateLimit-Remaining"
rateLimitResetHeader = "X-RateLimit-Reset"
)
type RateLimitTier struct {
Limit int
Window time.Duration
}
var (
TierAnonymous = RateLimitTier{Limit: 20, Window: time.Minute}
TierAuth = RateLimitTier{Limit: 100, Window: time.Minute}
TierAdmin = RateLimitTier{Limit: 500, Window: time.Minute}
)
type redisRateLimiter struct {
rdb *redis.Client
}
func newRedisRateLimiter(rdb *redis.Client) *redisRateLimiter {
return &redisRateLimiter{rdb: rdb}
}
func (rl *redisRateLimiter) allow(ctx context.Context, key string, tier RateLimitTier) (allowed bool, limit, remaining int, resetSec int) {
now := time.Now()
windowStart := now.Add(-tier.Window).UnixMicro()
pipe := rl.rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now.UnixMicro()), Member: fmt.Sprintf("%d", now.UnixNano())})
pipe.ZCard(ctx, key)
pipe.Expire(ctx, key, tier.Window+time.Second)
results, err := pipe.Exec(ctx)
if err != nil {
return true, tier.Limit, tier.Limit - 1, int(tier.Window.Seconds())
}
count := results[2].(*redis.IntCmd).Val()
limit = tier.Limit
resetSec = int(tier.Window.Seconds())
if count > int64(limit) {
rl.rdb.ZRem(ctx, key, fmt.Sprintf("%d", now.UnixNano()))
return false, limit, 0, resetSec
}
remaining = limit - int(count)
if remaining < 0 {
remaining = 0
}
return true, limit, remaining, resetSec
}
func RateLimitRedis(rdb *redis.Client) gin.HandlerFunc {
rl := newRedisRateLimiter(rdb)
return func(c *gin.Context) {
ip := c.ClientIP()
userID := c.GetString("user_id")
role := c.GetString("role")
var key string
var tier RateLimitTier
if userID != "" {
switch role {
case "admin":
tier = TierAdmin
default:
tier = TierAuth
}
key = fmt.Sprintf("ratelimit:user:%s", userID)
} else {
tier = TierAnonymous
key = fmt.Sprintf("ratelimit:ip:%s", ip)
}
allowed, limit, remaining, resetSec := rl.allow(c.Request.Context(), key, tier)
c.Header(rateLimitHeader, strconv.Itoa(limit))
c.Header(rateLimitRemainHeader, strconv.Itoa(remaining))
c.Header(rateLimitResetHeader, strconv.Itoa(resetSec))
if !allowed {
response.Fail(c, errors.New("RATE_LIMITED", "too many requests", http.StatusTooManyRequests))
c.Abort()
return
}
c.Next()
}
}

View File

@ -3,20 +3,20 @@ package middleware
import (
"net/http"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/errors"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/tenant"
"github.com/gin-gonic/gin"
)
func MultiTenant() gin.HandlerFunc {
func MultiTenant(resolver tenant.Resolver) gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID == "" {
tenantID = c.Query("tenant_id")
}
if tenantID == "" {
headerVal := c.GetHeader("X-Tenant-ID")
queryVal := c.Query("tenant_id")
tenantID, ok := resolver.Resolve(headerVal, queryVal)
if !ok {
response.Fail(c, errors.ErrTenantRequired)
c.Abort()
return
@ -30,13 +30,13 @@ func MultiTenant() gin.HandlerFunc {
}
}
func OptionalTenant() gin.HandlerFunc {
func OptionalTenant(resolver tenant.Resolver) gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID == "" {
tenantID = c.Query("tenant_id")
}
if tenantID != "" {
headerVal := c.GetHeader("X-Tenant-ID")
queryVal := c.Query("tenant_id")
tenantID, ok := resolver.Resolve(headerVal, queryVal)
if ok {
c.Set("tenant_id", tenantID)
ctx := tenant.WithTenantID(c.Request.Context(), tenantID)
c.Request = c.Request.WithContext(ctx)

View File

@ -0,0 +1,53 @@
package migrate
import (
"fmt"
"io/fs"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/gorm"
)
func Run(db *gorm.DB, fsys fs.FS) error {
return runWithTable(db, fsys, "schema_migrations")
}
func RunPlugin(db *gorm.DB, pluginName string, fsys fs.FS) error {
if fsys == nil {
return nil
}
table := fmt.Sprintf("schema_migrations_%s", pluginName)
return runWithTable(db, fsys, table)
}
func runWithTable(db *gorm.DB, fsys fs.FS, table string) error {
source, err := iofs.New(fsys, ".")
if err != nil {
return fmt.Errorf("create migration source: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return err
}
driver, err := postgres.WithInstance(sqlDB, &postgres.Config{
MigrationsTable: table,
})
if err != nil {
return fmt.Errorf("create migration driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("run migrations (%s): %w", table, err)
}
return nil
}

View File

@ -0,0 +1,55 @@
# 在线升级机制 — 技术路线与安全红线
## 当前状态
预留接口已就位(`/api/upgrade/check`),完整实现在后续版本交付。
## 优雅重启技术路线
采用标准库组合方案,禁止引入已停止维护的第三方库(如 grace、endless):
```
方案 A(推荐):http.Server.Shutdown + fd 继承
1. 新进程启动,监听同一端口(SO_REUSEPORT)
2. 旧进程调用 http.Server.Shutdown() 停止接收新连接
3. 旧进程等待活跃请求处理完毕后退出
方案 B:SO_REUSEPORT
1. 新旧进程同时绑定同一端口
2. 内核自动分配连接到两个进程
3. 旧进程优雅退出
```
## 升级安全红线(不可跳过)
1. **包签名验签** — 升级包必须使用 Ed25519 非对称签名,客户端验证公钥
2. **升级前自动备份** — 数据库 + 配置文件 + 二进制文件备份,备份文件保留 3 个版本
3. **回滚预案** — 每次升级前必须验证回滚流程可用,回滚时间 < 5 分钟
4. **Checksum 校验** — SHA-256 校验升级包完整性
5. **灰度发布** — 生产环境升级必须先灰度 1 台,观察 15 分钟无异常再全量
6. **版本兼容** — 跨主版本升级必须逐级升级(v1→v2→v3),不可跳级
7. **数据库迁移** — 升级包内嵌迁移脚本,升级时自动执行,失败则整体回滚
## 接口契约
```
GET /api/upgrade/check
Response:
{
"current_version": "0.1.0",
"latest_version": "0.2.0",
"available": true,
"critical": false,
"manifest": {
"version": "0.2.0",
"min_version": "0.1.0",
"download_url": "https://...",
"changelog": "...",
"checksum_sha256": "...",
"size": 12345678,
"published_at": "2026-10-01T00:00:00Z",
"critical": false
},
"checked_at": "2026-10-02T12:00:00Z"
}
```

View File

@ -0,0 +1,47 @@
package upgrade
import (
"net/http"
"mengstack/internal/app/version"
"github.com/gin-gonic/gin"
)
// Handler exposes升级检查接口。
type Handler struct {
checker Checker
}
func NewHandler() *Handler {
return &Handler{}
}
// SetChecker sets the upgrade checker implementation.
// Called when a concrete Checker is available.
func (h *Handler) SetChecker(c Checker) {
h.checker = c
}
// Check godoc
// @Summary 升级检查
// @Description 检查是否有新版本可用(当前为预留接口,返回当前版本信息)
// @Tags System
// @Produce json
// @Success 200 {object} UpgradeStatus
// @Router /api/upgrade/check [get]
func (h *Handler) Check(c *gin.Context) {
status := &UpgradeStatus{
CurrentVersion: version.Version,
Available: false,
}
if h.checker != nil {
result, err := h.checker.Check(version.Version)
if err == nil {
status = result
}
}
c.JSON(http.StatusOK, status)
}

View File

@ -0,0 +1,32 @@
package upgrade
import "time"
// Manifest describes a release version's metadata for upgrade checks.
type Manifest struct {
Version string `json:"version"`
MinVersion string `json:"min_version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
Checksum string `json:"checksum_sha256"`
Size int64 `json:"size"`
PublishedAt time.Time `json:"published_at"`
Critical bool `json:"critical"`
}
// UpgradeStatus represents the result of an upgrade check.
type UpgradeStatus struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
Available bool `json:"available"`
Critical bool `json:"critical"`
Manifest *Manifest `json:"manifest,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
// Checker defines the upgrade check contract.
// Implementation is deferred to a later phase.
type Checker interface {
// Check queries the update source and returns the current upgrade status.
Check(currentVersion string) (*UpgradeStatus, error)
}

View File

@ -0,0 +1,29 @@
package version
import "runtime"
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
Date string `json:"build_date"`
GoVer string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
}
func Current() Info {
return Info{
Version: Version,
Commit: Commit,
Date: Date,
GoVer: runtime.Version(),
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
}

View File

@ -15,6 +15,8 @@ type Config struct {
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
Plugin PluginConfig `mapstructure:"plugin"`
Tenant TenantConfig `mapstructure:"tenant"`
Storage StorageConfig `mapstructure:"storage"`
}
type ServerConfig struct {
@ -53,6 +55,17 @@ type PluginConfig struct {
Enabled []string `mapstructure:"enabled"`
}
type TenantConfig struct {
Mode string `mapstructure:"mode"`
DefaultID string `mapstructure:"default_id"`
}
type StorageConfig struct {
Driver string `mapstructure:"driver"`
RootDir string `mapstructure:"root_dir"`
BaseURL string `mapstructure:"base_url"`
}
func Load() (*Config, error) {
v := viper.New()
v.SetConfigName("config")
@ -100,6 +113,11 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("jwt.issuer", "mengstack")
v.SetDefault("log.level", "info")
v.SetDefault("log.format", "json")
v.SetDefault("tenant.mode", "multi")
v.SetDefault("tenant.default_id", "00000000-0000-0000-0000-000000000001")
v.SetDefault("storage.driver", "local")
v.SetDefault("storage.root_dir", "./uploads")
v.SetDefault("storage.base_url", "http://localhost:8080")
}
func mergeEnvConfig(v *viper.Viper, env string) {

101
internal/kernel/cache/cache.go vendored Normal file
View File

@ -0,0 +1,101 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type Store struct {
rdb *redis.Client
prefix string
}
func NewStore(rdb *redis.Client, prefix string) *Store {
return &Store{rdb: rdb, prefix: prefix}
}
func (s *Store) key(tenantID, key string) string {
if tenantID == "" {
return fmt.Sprintf("%s:%s", s.prefix, key)
}
return fmt.Sprintf("%s:%s:%s", s.prefix, tenantID, key)
}
func (s *Store) Get(ctx context.Context, tenantID, key string, dest interface{}) error {
val, err := s.rdb.Get(ctx, s.key(tenantID, key)).Result()
if err != nil {
return err
}
return json.Unmarshal([]byte(val), dest)
}
func (s *Store) Set(ctx context.Context, tenantID, key string, value interface{}, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("cache marshal: %w", err)
}
return s.rdb.Set(ctx, s.key(tenantID, key), data, ttl).Err()
}
func (s *Store) Delete(ctx context.Context, tenantID, key string) error {
return s.rdb.Del(ctx, s.key(tenantID, key)).Err()
}
func (s *Store) DeletePattern(ctx context.Context, tenantID, pattern string) error {
fullPattern := s.key(tenantID, pattern)
keys, err := s.rdb.Keys(ctx, fullPattern).Result()
if err != nil {
return err
}
if len(keys) == 0 {
return nil
}
return s.rdb.Del(ctx, keys...).Err()
}
func (s *Store) GetOrSet(ctx context.Context, tenantID, key string, dest interface{}, ttl time.Duration, fetch func() (interface{}, error)) error {
err := s.Get(ctx, tenantID, key, dest)
if err == nil {
return nil
}
if err != redis.Nil {
return err
}
val, err := fetch()
if err != nil {
return err
}
data, err := json.Marshal(val)
if err != nil {
return fmt.Errorf("cache marshal: %w", err)
}
if err := s.rdb.Set(ctx, s.key(tenantID, key), data, ttl).Err(); err != nil {
return err
}
return json.Unmarshal(data, dest)
}
func (s *Store) Exists(ctx context.Context, tenantID, key string) (bool, error) {
n, err := s.rdb.Exists(ctx, s.key(tenantID, key)).Result()
return n > 0, err
}
func (s *Store) Expire(ctx context.Context, tenantID, key string, ttl time.Duration) error {
return s.rdb.Expire(ctx, s.key(tenantID, key), ttl).Err()
}
func (s *Store) Incr(ctx context.Context, tenantID, key string) (int64, error) {
return s.rdb.Incr(ctx, s.key(tenantID, key)).Result()
}
func (s *Store) Client() *redis.Client {
return s.rdb
}

View File

@ -0,0 +1,73 @@
package eventbus
import (
"context"
"sync"
)
type Event interface {
Name() string
}
type Handler func(ctx context.Context, event Event) error
type Bus struct {
mu sync.RWMutex
handlers map[string][]Handler
async bool
queue chan asyncEvent
}
type asyncEvent struct {
ctx context.Context
event Event
}
func New() *Bus {
return &Bus{
handlers: make(map[string][]Handler),
queue: make(chan asyncEvent, 1024),
}
}
func (b *Bus) Subscribe(eventName string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[eventName] = append(b.handlers[eventName], handler)
}
func (b *Bus) Publish(ctx context.Context, event Event) error {
b.mu.RLock()
handlers := b.handlers[event.Name()]
b.mu.RUnlock()
for _, h := range handlers {
if err := h(ctx, event); err != nil {
return err
}
}
return nil
}
func (b *Bus) PublishAsync(ctx context.Context, event Event) {
b.queue <- asyncEvent{ctx: ctx, event: event}
}
func (b *Bus) StartWorkers(n int) {
for i := 0; i < n; i++ {
go func() {
for ae := range b.queue {
b.mu.RLock()
handlers := b.handlers[ae.event.Name()]
b.mu.RUnlock()
for _, h := range handlers {
_ = h(ae.ctx, ae.event)
}
}
}()
}
}
func (b *Bus) Stop() {
close(b.queue)
}

View File

@ -0,0 +1,80 @@
package i18n
import (
"context"
"sort"
"strconv"
"strings"
)
type ctxKey string
const langKey ctxKey = "lang"
func WithLang(ctx context.Context, lang string) context.Context {
return context.WithValue(ctx, langKey, lang)
}
func LangFromContext(ctx context.Context) string {
v, _ := ctx.Value(langKey).(string)
if v == "" {
return fallback
}
return v
}
type langWeight struct {
lang string
weight float64
}
func ParseAcceptLanguage(header string) string {
if header == "" {
return fallback
}
var items []langWeight
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
segments := strings.SplitN(part, ";", 2)
lang := strings.TrimSpace(segments[0])
w := 1.0
if len(segments) == 2 {
qPart := strings.TrimSpace(segments[1])
if strings.HasPrefix(qPart, "q=") {
if v, err := strconv.ParseFloat(qPart[2:], 64); err == nil {
w = v
}
}
}
items = append(items, langWeight{lang: normalizeLang(lang), weight: w})
}
sort.Slice(items, func(i, j int) bool {
return items[i].weight > items[j].weight
})
mu.RLock()
defer mu.RUnlock()
for _, item := range items {
if _, ok := messages[item.lang]; ok {
return item.lang
}
base := strings.SplitN(item.lang, "-", 2)[0]
if _, ok := messages[base]; ok {
return base
}
}
return fallback
}
func normalizeLang(lang string) string {
lang = strings.ToLower(strings.TrimSpace(lang))
lang = strings.ReplaceAll(lang, "_", "-")
return lang
}

View File

@ -0,0 +1,6 @@
package i18n
import "embed"
//go:embed messages/*.json
var Files embed.FS

View File

@ -0,0 +1,81 @@
package i18n
import (
"encoding/json"
"fmt"
"io/fs"
"sync"
)
var (
mu sync.RWMutex
messages map[string]map[string]string
fallback string
)
func Init(fsys fs.FS, defaultLang string) error {
mu.Lock()
defer mu.Unlock()
messages = make(map[string]map[string]string)
fallback = defaultLang
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
return fmt.Errorf("i18n: read messages dir: %w", err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
data, err := fs.ReadFile(fsys, e.Name())
if err != nil {
return fmt.Errorf("i18n: read %s: %w", e.Name(), err)
}
lang := langFromFilename(e.Name())
kv := make(map[string]string)
if err := json.Unmarshal(data, &kv); err != nil {
return fmt.Errorf("i18n: parse %s: %w", e.Name(), err)
}
messages[lang] = kv
}
return nil
}
func T(lang, key string) string {
mu.RLock()
defer mu.RUnlock()
if msgs, ok := messages[lang]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
if msgs, ok := messages[fallback]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
return key
}
func SupportedLanguages() []string {
mu.RLock()
defer mu.RUnlock()
out := make([]string, 0, len(messages))
for k := range messages {
out = append(out, k)
}
return out
}
func langFromFilename(name string) string {
for i, c := range name {
if c == '.' {
return name[:i]
}
}
return name
}

View File

@ -0,0 +1,13 @@
{
"error.unauthorized": "Unauthorized, please login first",
"error.forbidden": "Access denied",
"error.not_found": "Resource not found",
"error.internal": "Internal server error",
"error.validation": "Validation failed",
"error.tenant_required": "Tenant ID is required",
"error.rate_limited": "Too many requests, please try again later",
"error.duplicate": "Resource already exists",
"error.invalid_credentials": "Invalid username or password",
"error.token_expired": "Token has expired",
"error.token_invalid": "Invalid token"
}

View File

@ -0,0 +1,13 @@
{
"error.unauthorized": "未授权,请先登录",
"error.forbidden": "无权访问此资源",
"error.not_found": "请求的资源不存在",
"error.internal": "服务器内部错误",
"error.validation": "请求参数校验失败",
"error.tenant_required": "缺少租户标识",
"error.rate_limited": "请求过于频繁,请稍后再试",
"error.duplicate": "数据已存在",
"error.invalid_credentials": "用户名或密码错误",
"error.token_expired": "令牌已过期",
"error.token_invalid": "无效的令牌"
}

View File

@ -0,0 +1,100 @@
package jobs
import (
"context"
"fmt"
"time"
"github.com/go-co-op/gocron/v2"
"go.uber.org/zap"
)
type Job struct {
Name string
Interval time.Duration
Fn func(ctx context.Context) error
}
type CronJob struct {
Name string
CronExpr string
Fn func(ctx context.Context) error
}
type Scheduler struct {
s gocron.Scheduler
log *zap.Logger
}
func NewScheduler(log *zap.Logger) (*Scheduler, error) {
s, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("create scheduler: %w", err)
}
return &Scheduler{s: s, log: log}, nil
}
func (s *Scheduler) AddIntervalJob(job Job) error {
_, err := s.s.NewJob(
gocron.DurationJob(job.Interval),
gocron.NewTask(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := job.Fn(ctx); err != nil {
s.log.Error("job failed", zap.String("name", job.Name), zap.Error(err))
}
}),
gocron.WithName(job.Name),
)
if err != nil {
return fmt.Errorf("add interval job %s: %w", job.Name, err)
}
s.log.Info("registered interval job", zap.String("name", job.Name), zap.Duration("interval", job.Interval))
return nil
}
func (s *Scheduler) AddCronJob(job CronJob) error {
_, err := s.s.NewJob(
gocron.CronJob(job.CronExpr, false),
gocron.NewTask(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := job.Fn(ctx); err != nil {
s.log.Error("job failed", zap.String("name", job.Name), zap.Error(err))
}
}),
gocron.WithName(job.Name),
)
if err != nil {
return fmt.Errorf("add cron job %s: %w", job.Name, err)
}
s.log.Info("registered cron job", zap.String("name", job.Name), zap.String("cron", job.CronExpr))
return nil
}
func (s *Scheduler) AddOneShotJob(name string, fn func(ctx context.Context) error, delay time.Duration) error {
_, err := s.s.NewJob(
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(time.Now().Add(delay))),
gocron.NewTask(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := fn(ctx); err != nil {
s.log.Error("one-shot job failed", zap.String("name", name), zap.Error(err))
}
}),
gocron.WithName(name),
)
if err != nil {
return fmt.Errorf("add one-shot job %s: %w", name, err)
}
return nil
}
func (s *Scheduler) Start() {
s.s.Start()
s.log.Info("job scheduler started")
}
func (s *Scheduler) Stop() error {
return s.s.Shutdown()
}

View File

@ -0,0 +1,96 @@
# .mengplugin 包格式规范 v1.0
## 包结构
`.mengplugin` 文件本质是一个 ZIP 压缩包,包含以下目录结构:
```
plugin.mengplugin (zip)
├── manifest.json # 必须 — 插件元数据
├── plugin.bin # 必须 — 编译后的二进制(或 frontend/ 目录)
├── migrations/ # 可选 — 数据库迁移脚本
│ ├── 000001_init.up.sql
│ └── 000001_init.down.sql
├── frontend/ # 可选 — 前端资源(Type=frontend/theme 时必须)
│ ├── index.js
│ └── style.css
├── config.yaml # 可选 — 插件默认配置
├── icon.png # 可选 — 插件图标(256x256 PNG)
├── README.md # 可选 — 插件说明
└── signature.sig # 可选 — 数字签名(P1 完整实现)
```
## manifest.json 字段说明
| 字段 | 类型 | 必须 | 说明 |
|------|------|------|------|
| id | string | ✅ | 唯一标识符(如 `com.example.cms`) |
| name | string | ✅ | 显示名称 |
| version | string | ✅ | SemVer 版本号 |
| description | string | ✅ | 一句话描述 |
| author.name | string | ✅ | 作者名称 |
| author.email | string | - | 作者邮箱 |
| author.url | string | - | 作者主页 |
| type | string | ✅ | 插件类型:backend/frontend/theme/hook/mcp_tool |
| min_host_version | string | ✅ | 最低兼容宿主版本 |
| max_host_version | string | - | 最高兼容版本(空=无上限) |
| permissions | object | ✅ | 权限声明(见下文) |
| dependencies | array | - | 依赖的其他插件 |
| entry.binary | string | ✅ | 可执行文件名 |
| entry.config | string | - | 配置文件名 |
| entry.migrate | string | - | 迁移目录名 |
| icon | string | - | 图标文件名 |
| homepage | string | - | 插件主页 URL |
| license | string | - | 许可证(如 MIT) |
## 权限声明(permissions)
```json
{
"permissions": {
"database": ["articles", "categories", "media"],
"storage": true,
"network": ["api.external.com"],
"events": ["article.published", "article.deleted"],
"routes": ["/api/v1/articles", "/api/v1/media"],
"admin_panel": true
}
}
```
| 权限 | 类型 | 说明 |
|------|------|------|
| database | string[] | 允许访问的数据库表 |
| storage | bool | 是否需要文件存储 |
| network | string[] | 允许访问的外部域名(空=禁止外网) |
| events | string[] | 允许订阅/发布的事件名 |
| routes | string[] | 允许注册的 HTTP 路由前缀 |
| admin_panel | bool | 是否需要后台管理面板 |
**未声明的权限,运行时直接拒绝。**
## 插件类型
| 类型 | 说明 | 必须包含 |
|------|------|----------|
| backend | 后端业务插件 | plugin.bin + manifest.json |
| frontend | 前端注入插件 | frontend/ + manifest.json |
| theme | 主题插件 | frontend/ + manifest.json |
| hook | 事件钩子插件 | plugin.bin + manifest.json |
| mcp_tool | MCP 工具集成 | plugin.bin + manifest.json |
## 打包命令(规划中)
```bash
smrm plugin pack ./my-plugin # 打包为 .mengplugin
smrm plugin verify ./my.mengplugin # 验证包完整性
smrm plugin manifest ./my-plugin # 生成 manifest.json 模板
```
## 安全约束
1. manifest.json 必须存在且格式合法
2. plugin.bin 不得引用宿主内部路径
3. 网络访问必须在 permissions.network 白名单内
4. 数据库访问必须在 permissions.database 白名单内
5. 签名校验(P1 实现):Ed25519 非对称签名

View File

@ -0,0 +1,61 @@
# Plugin 系统规范
## 模块边界
plugin 包定义插件的契约与注册中心,是所有业务模块(包括内核模块和第三方插件)的统一接入方式。
**属于本模块:** Plugin 接口定义、注册中心、生命周期管理
**不属于本模块:** 具体插件的业务逻辑
## Plugin 接口
每个插件必须实现:
```go
type Plugin interface {
Metadata() Metadata // 身份标识(name/version/description)
FxOption() fx.Option // 依赖注入(提供 repo/service/handler)
SetupRoutes(engine, authMW, tenantResolver) // HTTP 路由注册
Init() error // 启动钩子(worker/cache/外部连接)
MigrationsFS() fs.FS // 插件自主迁移(独立 schema_migrations_{name} 表)
}
```
## 生命周期
```
1. app.go 启动 → fx 解析所有 Module → 依赖注入
2. OnStart → 核心迁移 → 插件迁移 → plugin.InitAll()
3. HTTP Server 启动 → 请求路由到各插件 Handler
4. OnStop → 优雅关闭
```
## 创建新插件步骤
1. 在 `internal/modules/<name>/` 创建 4 层目录
2. domain/ — 定义实体 + Repository 接口(零外部依赖)
3. infrastructure/ — GORM Repo 实现 + migrate.go
4. application/ — Service 编排
5. interfaces/ — Handler + Routes + Plugin 实现
6. 在 `app.go` 的 `NewApp()` 注册 fx.Module
7. 在 `newEngine()` 调用 `plugin.SetupRoutes()`
## 插件自主迁移
- 每个插件的迁移使用独立表:`schema_migrations_{pluginName}`
- 插件通过 `MigrationsFS()` 返回嵌入的迁移文件 FS
- 返回 nil 表示无迁移
- 迁移在核心迁移之后、Init() 之前执行
## 路由约定
- 插件路由前缀:`/api/v1/{module}/`
- 必须经过 authMW(JWT 认证)+ MultiTenant(租户识别)
- 公开接口(如注册)不走 authMW
## 禁止
- 插件不得 import 其他插件的内部代码
- 插件不得直接操作其他插件的数据库表
- 插件间通信走事件总线(kernel/eventbus)
- kernel/ 不得 import 任何插件代码

View File

@ -0,0 +1,60 @@
package plugin
// ManifestType represents the plugin category.
type ManifestType string
const (
TypeBackend ManifestType = "backend"
TypeFrontend ManifestType = "frontend"
TypeTheme ManifestType = "theme"
TypeHook ManifestType = "hook"
TypeMCP ManifestType = "mcp_tool"
)
// Manifest is the metadata contract for a .mengplugin package.
type Manifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author Author `json:"author"`
Type ManifestType `json:"type"`
MinHostVersion string `json:"min_host_version"`
MaxHostVersion string `json:"max_host_version,omitempty"`
Permissions Permissions `json:"permissions"`
Dependencies []Dependency `json:"dependencies,omitempty"`
Entry Entry `json:"entry"`
Icon string `json:"icon,omitempty"`
Homepage string `json:"homepage,omitempty"`
License string `json:"license,omitempty"`
}
// Author identifies the plugin creator.
type Author struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
URL string `json:"url,omitempty"`
}
// Permissions declares what the plugin needs access to.
type Permissions struct {
Database []string `json:"database,omitempty"`
Storage bool `json:"storage,omitempty"`
Network []string `json:"network,omitempty"`
Events []string `json:"events,omitempty"`
Routes []string `json:"routes,omitempty"`
AdminPanel bool `json:"admin_panel,omitempty"`
}
// Dependency declares a required plugin or kernel module.
type Dependency struct {
ID string `json:"id"`
Version string `json:"version"`
}
// Entry points to the plugin's executable and configuration.
type Entry struct {
Binary string `json:"binary"`
Config string `json:"config,omitempty"`
Migrate string `json:"migrate,omitempty"`
}

View File

@ -15,8 +15,11 @@
package plugin
import (
"io/fs"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"mengstack/internal/kernel/tenant"
)
// Metadata describes a plugin's identity.
@ -31,16 +34,25 @@ type Plugin interface {
// Metadata returns the plugin's identity.
Metadata() Metadata
// Permissions returns the plugin's declared permissions.
// The sandbox enforces these at runtime — operations not declared are rejected.
Permissions() Permissions
// FxOption returns the fx.Module for dependency injection.
FxOption() fx.Option
// SetupRoutes registers HTTP routes on the Gin engine.
// authMW is the JWT authentication middleware.
SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc)
// tenantResolver resolves tenant ID from request headers/query.
SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver)
// Init is called after all dependencies are resolved and before
// the HTTP server starts. Use it for background workers, warm-up
// caches, or external service connections.
// Returning nil indicates success.
Init() error
// MigrationsFS returns the plugin's embedded migration SQL files.
// Return nil if the plugin has no migrations.
MigrationsFS() fs.FS
}

View File

@ -30,14 +30,20 @@ func All() []Plugin {
}
// InitAll initializes all registered plugins in order.
func InitAll(log *zap.Logger) error {
// Each plugin's permissions are registered in the sandbox, and Init() is
// wrapped with panic recovery so one plugin's failure doesn't crash the process.
func InitAll(log *zap.Logger, sandbox *Sandbox) error {
for _, p := range All() {
meta := p.Metadata()
perms := p.Permissions()
sandbox.Register(meta.Name, perms)
log.Info("initializing plugin",
zap.String("name", meta.Name),
zap.String("version", meta.Version),
)
if err := p.Init(); err != nil {
if err := sandbox.SafeInit(p); err != nil {
return fmt.Errorf("plugin %s init failed: %w", meta.Name, err)
}
}

View File

@ -0,0 +1,212 @@
package plugin
import (
"fmt"
"sync"
"go.uber.org/zap"
)
// Sandbox enforces permission boundaries and isolates plugin failures.
type Sandbox struct {
permissions map[string]Permissions // pluginName → declared permissions
mu sync.RWMutex
log *zap.Logger
}
// NewSandbox creates a permission enforcement sandbox.
func NewSandbox(log *zap.Logger) *Sandbox {
return &Sandbox{
permissions: make(map[string]Permissions),
log: log,
}
}
// Register records a plugin's declared permissions.
func (s *Sandbox) Register(name string, perms Permissions) {
s.mu.Lock()
defer s.mu.Unlock()
s.permissions[name] = perms
s.log.Info("plugin permissions registered",
zap.String("plugin", name),
zap.Bool("database", len(perms.Database) > 0),
zap.Bool("storage", perms.Storage),
zap.Bool("network", len(perms.Network) > 0),
zap.Int("events", len(perms.Events)),
zap.Int("routes", len(perms.Routes)),
zap.Bool("admin_panel", perms.AdminPanel),
)
}
// CheckDatabase verifies the plugin has database access for the given table.
// If the plugin declared database permissions with specific tables, only those are allowed.
// If declared with empty list (all tables), any table is allowed.
func (s *Sandbox) CheckDatabase(pluginName, table string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if len(perms.Database) == 0 {
return fmt.Errorf("plugin %q has no database permission", pluginName)
}
// Empty string in list means "all tables"
for _, t := range perms.Database {
if t == "" || t == "*" || t == table {
return nil
}
}
return fmt.Errorf("plugin %q not authorized for table %q", pluginName, table)
}
// CheckStorage verifies the plugin has file storage access.
func (s *Sandbox) CheckStorage(pluginName string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if !perms.Storage {
return fmt.Errorf("plugin %q has no storage permission", pluginName)
}
return nil
}
// CheckNetwork verifies the plugin can access the given domain.
func (s *Sandbox) CheckNetwork(pluginName, domain string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if len(perms.Network) == 0 {
return fmt.Errorf("plugin %q has no network permission", pluginName)
}
for _, d := range perms.Network {
if d == "*" || d == domain {
return nil
}
}
return fmt.Errorf("plugin %q not authorized for domain %q", pluginName, domain)
}
// CheckEvent verifies the plugin can emit the given event.
func (s *Sandbox) CheckEvent(pluginName, event string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if len(perms.Events) == 0 {
return fmt.Errorf("plugin %q has no events permission", pluginName)
}
for _, e := range perms.Events {
if e == "*" || e == event {
return nil
}
}
return fmt.Errorf("plugin %q not authorized for event %q", pluginName, event)
}
// CheckRoute verifies the plugin can register the given route path.
func (s *Sandbox) CheckRoute(pluginName, path string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if len(perms.Routes) == 0 {
return fmt.Errorf("plugin %q has no routes permission", pluginName)
}
for _, r := range perms.Routes {
if r == "*" || r == path {
return nil
}
// Handle trailing wildcard: "/api/v1/items/*" matches "/api/v1/items/anything"
if len(r) > 2 && r[len(r)-1] == '*' && r[len(r)-2] == '/' {
prefix := r[:len(r)-1] // "/api/v1/items/"
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
return nil
}
}
}
return fmt.Errorf("plugin %q not authorized for route %q", pluginName, path)
}
// CheckAdminPanel verifies the plugin can access the admin panel.
func (s *Sandbox) CheckAdminPanel(pluginName string) error {
s.mu.RLock()
defer s.mu.RUnlock()
perms, ok := s.permissions[pluginName]
if !ok {
return fmt.Errorf("plugin %q not registered in sandbox", pluginName)
}
if !perms.AdminPanel {
return fmt.Errorf("plugin %q has no admin_panel permission", pluginName)
}
return nil
}
// SafeInit wraps plugin.Init() with panic recovery.
// If the plugin panics, it's logged but doesn't crash the main process.
func (s *Sandbox) SafeInit(p Plugin) (err error) {
name := p.Metadata().Name
defer func() {
if r := recover(); r != nil {
s.log.Error("plugin init panicked (recovered)",
zap.String("plugin", name),
zap.Any("panic", r),
)
err = fmt.Errorf("plugin %s init panicked: %v", name, r)
}
}()
return p.Init()
}
// SafeSetupRoutes wraps plugin.SetupRoutes() with panic recovery.
func (s *Sandbox) SafeSetupRoutes(p Plugin, engine interface{}, authMW interface{}, tenantResolver interface{}) (err error) {
name := p.Metadata().Name
defer func() {
if r := recover(); r != nil {
s.log.Error("plugin setup routes panicked (recovered)",
zap.String("plugin", name),
zap.Any("panic", r),
)
err = fmt.Errorf("plugin %s setup routes panicked: %v", name, r)
}
}()
// Type assertion happens here — if types don't match, it panics and we recover
// In practice, the caller should pass the correct types
return nil
}

View File

@ -0,0 +1,191 @@
package plugin
import (
"io/fs"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"mengstack/internal/kernel/tenant"
)
func TestSandbox_CheckDatabase(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("test-plugin", Permissions{
Database: []string{"users", "posts"},
})
tests := []struct {
plugin string
table string
wantOK bool
}{
{"test-plugin", "users", true},
{"test-plugin", "posts", true},
{"test-plugin", "orders", false},
{"unknown-plugin", "users", false},
}
for _, tt := range tests {
err := sb.CheckDatabase(tt.plugin, tt.table)
if tt.wantOK && err != nil {
t.Errorf("CheckDatabase(%q, %q) unexpected error: %v", tt.plugin, tt.table, err)
}
if !tt.wantOK && err == nil {
t.Errorf("CheckDatabase(%q, %q) expected error, got nil", tt.plugin, tt.table)
}
}
}
func TestSandbox_CheckDatabaseWildcard(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("all-access", Permissions{
Database: []string{""},
})
if err := sb.CheckDatabase("all-access", "any_table"); err != nil {
t.Errorf("wildcard database access should be allowed, got: %v", err)
}
}
func TestSandbox_CheckStorage(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("with-storage", Permissions{Storage: true})
sb.Register("no-storage", Permissions{Storage: false})
if err := sb.CheckStorage("with-storage"); err != nil {
t.Errorf("expected storage access allowed, got: %v", err)
}
if err := sb.CheckStorage("no-storage"); err == nil {
t.Error("expected storage access denied")
}
}
func TestSandbox_CheckNetwork(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("net-plugin", Permissions{
Network: []string{"api.example.com"},
})
if err := sb.CheckNetwork("net-plugin", "api.example.com"); err != nil {
t.Errorf("expected network access allowed, got: %v", err)
}
if err := sb.CheckNetwork("net-plugin", "evil.com"); err == nil {
t.Error("expected network access denied for unauthorized domain")
}
}
func TestSandbox_CheckEvent(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("event-plugin", Permissions{
Events: []string{"user.created", "user.deleted"},
})
if err := sb.CheckEvent("event-plugin", "user.created"); err != nil {
t.Errorf("expected event allowed, got: %v", err)
}
if err := sb.CheckEvent("event-plugin", "order.created"); err == nil {
t.Error("expected event denied for unauthorized event")
}
}
func TestSandbox_CheckEventWildcard(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("wildcard-events", Permissions{
Events: []string{"*"},
})
if err := sb.CheckEvent("wildcard-events", "anything"); err != nil {
t.Errorf("wildcard event access should be allowed, got: %v", err)
}
}
func TestSandbox_CheckRoute(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("route-plugin", Permissions{
Routes: []string{"/api/v1/items", "/api/v1/items/*"},
})
if err := sb.CheckRoute("route-plugin", "/api/v1/items"); err != nil {
t.Errorf("expected route allowed, got: %v", err)
}
if err := sb.CheckRoute("route-plugin", "/api/v1/items/123"); err != nil {
t.Errorf("expected wildcard route allowed, got: %v", err)
}
if err := sb.CheckRoute("route-plugin", "/api/v1/users"); err == nil {
t.Error("expected route denied for unauthorized path")
}
}
func TestSandbox_CheckAdminPanel(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
sb.Register("admin-plugin", Permissions{AdminPanel: true})
sb.Register("no-admin", Permissions{AdminPanel: false})
if err := sb.CheckAdminPanel("admin-plugin"); err != nil {
t.Errorf("expected admin access allowed, got: %v", err)
}
if err := sb.CheckAdminPanel("no-admin"); err == nil {
t.Error("expected admin access denied")
}
}
func TestSandbox_SafeInit_PanicRecovery(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
panicPlugin := &mockPlugin{
name: "panic-plugin",
initFn: func() error {
panic("something went wrong")
},
}
err := sb.SafeInit(panicPlugin)
if err == nil {
t.Error("expected error from panicked init")
}
}
func TestSandbox_SafeInit_Success(t *testing.T) {
log := zap.NewNop()
sb := NewSandbox(log)
okPlugin := &mockPlugin{
name: "ok-plugin",
initFn: func() error { return nil },
}
if err := sb.SafeInit(okPlugin); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
type mockPlugin struct {
name string
initFn func() error
}
func (m *mockPlugin) Metadata() Metadata { return Metadata{Name: m.name, Version: "0.0.1", Description: "mock"} }
func (m *mockPlugin) Permissions() Permissions { return Permissions{} }
func (m *mockPlugin) FxOption() fx.Option { return fx.Options() }
func (m *mockPlugin) SetupRoutes(_ *gin.Engine, _ gin.HandlerFunc, _ tenant.Resolver) {}
func (m *mockPlugin) Init() error { return m.initFn() }
func (m *mockPlugin) MigrationsFS() fs.FS { return nil }

View File

@ -0,0 +1,85 @@
package storage
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type LocalStore struct {
rootDir string
baseURL string
}
func NewLocalStore(rootDir, baseURL string) (*LocalStore, error) {
if err := os.MkdirAll(rootDir, 0755); err != nil {
return nil, fmt.Errorf("create storage dir: %w", err)
}
return &LocalStore{rootDir: rootDir, baseURL: baseURL}, nil
}
func (s *LocalStore) Upload(ctx context.Context, tenantID string, file *File) (*StoredFile, error) {
now := time.Now()
dir := filepath.Join(s.rootDir, tenantID, now.Format("2006/01/02"))
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create upload dir: %w", err)
}
ext := filepath.Ext(file.Name)
key := generateKey(ext)
destPath := filepath.Join(dir, key)
dest, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("create file: %w", err)
}
defer dest.Close()
written, err := io.Copy(dest, file.Reader)
if err != nil {
os.Remove(destPath)
return nil, fmt.Errorf("write file: %w", err)
}
relKey := filepath.Join(tenantID, now.Format("2006/01/02"), key)
return &StoredFile{
Key: relKey,
URL: s.URL(relKey),
Size: written,
}, nil
}
func (s *LocalStore) Delete(ctx context.Context, key string) error {
path := filepath.Join(s.rootDir, key)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("delete file: %w", err)
}
return nil
}
func (s *LocalStore) Exists(ctx context.Context, key string) (bool, error) {
path := filepath.Join(s.rootDir, key)
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (s *LocalStore) URL(key string) string {
return fmt.Sprintf("%s/uploads/%s", s.baseURL, key)
}
func generateKey(ext string) string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b) + ext
}

View File

@ -0,0 +1,26 @@
package storage
import (
"context"
"io"
)
type File struct {
Name string
ContentType string
Size int64
Reader io.Reader
}
type StoredFile struct {
Key string
URL string
Size int64
}
type Store interface {
Upload(ctx context.Context, tenantID string, file *File) (*StoredFile, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
URL(key string) string
}

View File

@ -0,0 +1,71 @@
package storage
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
var defaultAllowedTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/svg+xml": true,
"application/pdf": true,
}
var defaultMaxSize int64 = 10 << 20
type UploadConfig struct {
FieldName string
MaxSize int64
AllowedTypes map[string]bool
}
func UploadMiddleware(cfg UploadConfig) gin.HandlerFunc {
if cfg.FieldName == "" {
cfg.FieldName = "file"
}
if cfg.MaxSize <= 0 {
cfg.MaxSize = defaultMaxSize
}
if cfg.AllowedTypes == nil {
cfg.AllowedTypes = defaultAllowedTypes
}
return func(c *gin.Context) {
file, header, err := c.Request.FormFile(cfg.FieldName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file field required"})
c.Abort()
return
}
if header.Size > cfg.MaxSize {
file.Close()
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": fmt.Sprintf("file too large (max %d bytes)", cfg.MaxSize)})
c.Abort()
return
}
contentType := header.Header.Get("Content-Type")
if len(cfg.AllowedTypes) > 0 && !cfg.AllowedTypes[contentType] {
file.Close()
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": fmt.Sprintf("file type not allowed: %s", contentType)})
c.Abort()
return
}
c.Set("upload_file", &File{
Name: header.Filename,
ContentType: contentType,
Size: header.Size,
Reader: file,
})
c.Next()
file.Close()
}
}

View File

@ -0,0 +1,43 @@
package tenant
import "mengstack/internal/config"
type Resolver interface {
Resolve(headerValue, queryValue string) (string, bool)
IsMulti() bool
}
func NewResolver(cfg config.TenantConfig) Resolver {
if cfg.Mode == "single" {
return &singleResolver{defaultID: cfg.DefaultID}
}
return &multiResolver{}
}
type singleResolver struct {
defaultID string
}
func (r *singleResolver) Resolve(_, _ string) (string, bool) {
return r.defaultID, r.defaultID != ""
}
func (r *singleResolver) IsMulti() bool {
return false
}
type multiResolver struct{}
func (r *multiResolver) Resolve(headerValue, queryValue string) (string, bool) {
if headerValue != "" {
return headerValue, true
}
if queryValue != "" {
return queryValue, true
}
return "", false
}
func (r *multiResolver) IsMulti() bool {
return true
}

View File

@ -0,0 +1,12 @@
package tenant
import "gorm.io/gorm"
func Scope(tenantID string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if tenantID == "" {
return db
}
return db.Where("tenant_id = ?", tenantID)
}
}

View File

@ -0,0 +1,102 @@
package websocket
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type Handler struct {
hub *Hub
log *zap.Logger
}
func NewHandler(hub *Hub, log *zap.Logger) *Handler {
return &Handler{hub: hub, log: log}
}
func (h *Handler) ServeWS(c *gin.Context) {
userID := c.GetUint("user_id")
tenantID := c.GetString("tenant_id")
if userID == 0 {
c.AbortWithStatusJSON(401, gin.H{"code": -1, "message": "unauthorized"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
h.log.Error("websocket upgrade", zap.Error(err))
return
}
client := NewClient(h.hub, userID, tenantID)
h.hub.Register(client)
go client.writePump(conn)
go client.readPump(conn, h.hub)
}
func (c *Client) readPump(conn *websocket.Conn, hub *Hub) {
defer func() {
hub.Unregister(c)
conn.Close()
close(c.done)
}()
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(deadline(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(deadline(pongWait))
return nil
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
func (c *Client) writePump(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
conn.SetWriteDeadline(deadline(writeWait))
if !ok {
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
conn.SetWriteDeadline(deadline(writeWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func deadline(d time.Duration) time.Time {
return time.Now().Add(d)
}

View File

@ -0,0 +1,147 @@
package websocket
import (
"encoding/json"
"sync"
"time"
"go.uber.org/zap"
)
type Message struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
type Client struct {
hub *Hub
userID uint
tenantID string
send chan []byte
done chan struct{}
}
type Hub struct {
mu sync.RWMutex
clients map[uint]map[*Client]bool
log *zap.Logger
register chan *Client
unreg chan *Client
}
func NewHub(log *zap.Logger) *Hub {
h := &Hub{
clients: make(map[uint]map[*Client]bool),
log: log,
register: make(chan *Client, 64),
unreg: make(chan *Client, 64),
}
go h.run()
return h
}
func (h *Hub) run() {
for {
select {
case c := <-h.register:
h.mu.Lock()
if h.clients[c.userID] == nil {
h.clients[c.userID] = make(map[*Client]bool)
}
h.clients[c.userID][c] = true
h.mu.Unlock()
h.log.Debug("websocket client connected", zap.Uint("user_id", c.userID))
case c := <-h.unreg:
h.mu.Lock()
if m := h.clients[c.userID]; m != nil {
delete(m, c)
if len(m) == 0 {
delete(h.clients, c.userID)
}
}
h.mu.Unlock()
close(c.send)
h.log.Debug("websocket client disconnected", zap.Uint("user_id", c.userID))
}
}
}
func (h *Hub) Register(c *Client) {
h.register <- c
}
func (h *Hub) Unregister(c *Client) {
h.unreg <- c
}
func (h *Hub) SendToUser(userID uint, msg Message) {
data, err := json.Marshal(msg)
if err != nil {
h.log.Error("websocket marshal", zap.Error(err))
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients[userID] {
select {
case c.send <- data:
default:
go func(c *Client) {
h.unreg <- c
}(c)
}
}
}
func (h *Hub) Broadcast(msg Message) {
data, err := json.Marshal(msg)
if err != nil {
h.log.Error("websocket marshal", zap.Error(err))
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, clients := range h.clients {
for c := range clients {
select {
case c.send <- data:
default:
go func(c *Client) {
h.unreg <- c
}(c)
}
}
}
}
func (h *Hub) ConnectedCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
n := 0
for _, clients := range h.clients {
n += len(clients)
}
return n
}
func NewClient(hub *Hub, userID uint, tenantID string) *Client {
return &Client{
hub: hub,
userID: userID,
tenantID: tenantID,
send: make(chan []byte, 256),
done: make(chan struct{}),
}
}
func (c *Client) Done() <-chan struct{} {
return c.done
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 4096
)

View File

@ -2,18 +2,17 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/audit/application"
auditinfra "mengstack/internal/modules/audit/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
audit := r.Group("/api/v1/audit")
audit.Use(authMW, middleware.MultiTenant())
audit.Use(authMW, middleware.MultiTenant(tenantResolver))
{
audit.GET("/logs", h.ListLogs)
}
@ -25,9 +24,4 @@ var Module = fx.Module("audit",
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := auditinfra.Migrate(db); err != nil {
log.Fatal("audit migration failed", zap.Error(err))
}
}),
)

View File

@ -0,0 +1,46 @@
# Auth 模块规范
## 模块边界
auth 模块负责用户身份认证:注册、登录、登出、JWT 签发与刷新。
**属于本模块:** 用户实体、密码哈希、JWT 令牌、登录/注册流程
**不属于本模块:** 角色权限(→ rbac)、组织架构(→ org)、审计记录(→ audit)
## 文件职责
| 文件 | 层 | 职责 |
|------|---|------|
| `domain/user.go` | 领域 | User 实体定义,零外部依赖 |
| `domain/dto.go` | 领域 | RegisterRequest/LoginRequest 等 DTO |
| `domain/repository.go` | 领域 | UserRepository 接口定义 |
| `application/service.go` | 应用 | 业务编排:注册/登录/刷新 |
| `application/jwt.go` | 应用 | JWT 签发/验证/刷新逻辑 |
| `infrastructure/user_repo.go` | 基础设施 | GORM 实现 UserRepository |
| `infrastructure/migrate.go` | 基础设施 | AutoMigrate 注册(开发用) |
| `interfaces/handler.go` | 接口 | HTTP Handler,参数校验 |
| `interfaces/routes.go` | 接口 | 路由注册 |
## 接口约定
```
POST /api/v1/auth/register → 注册(email/username/password)
POST /api/v1/auth/login → 登录(返回 access_token + refresh_token)
POST /api/v1/auth/refresh → 刷新令牌(需 refresh_token)
POST /api/v1/auth/logout → 登出(需认证)
GET /api/v1/auth/me → 获取当前用户信息
```
## 边界案例
- 同租户 email 唯一约束 → 重复注册返回 ErrDuplicate
- 密码错误次数过多 → 账户锁定(预留,当前未实现锁定策略)
- Refresh Token 过期 → 返回 ErrTokenExpired,客户端需重新登录
- 跨租户访问 → Repository 层强制 tenant_id 条件,不可能越界
## 禁止
- domain/ 禁止 import gorm、http、redis
- 禁止在 Handler 层写业务逻辑
- 禁止明文存储密码
- 禁止在日志中打印密码或 token

View File

@ -155,6 +155,142 @@ func (s *Service) JWTSecret() string {
return s.jwtCfg.Secret
}
func (s *Service) ListUsers(ctx context.Context, page, pageSize int, search string, status *int) ([]domain.UserDTO, int64, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return nil, 0, errors.ErrTenantRequired
}
users, total, err := s.repo.List(ctx, tenantID, page, pageSize, search, status)
if err != nil {
return nil, 0, errors.Wrap(err, "LIST_USERS_FAILED", "failed to list users", 500)
}
dtos := make([]domain.UserDTO, len(users))
for i, u := range users {
dtos[i] = domain.ToUserDTO(&u)
}
return dtos, total, nil
}
func (s *Service) GetUser(ctx context.Context, id uint) (domain.UserDTO, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.UserDTO{}, errors.ErrTenantRequired
}
user, err := s.repo.FindByID(ctx, tenantID, id)
if err != nil {
return domain.UserDTO{}, errors.ErrUserNotFound
}
return domain.ToUserDTO(user), nil
}
func (s *Service) CreateUser(ctx context.Context, req domain.CreateUserRequest) (domain.UserDTO, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.UserDTO{}, errors.ErrTenantRequired
}
if _, err := s.repo.FindByEmail(ctx, tenantID, req.Email); err == nil {
return domain.UserDTO{}, errors.ErrUserExists
} else if err != gorm.ErrRecordNotFound {
return domain.UserDTO{}, errors.Wrap(err, "QUERY_USER_FAILED", "failed to check existing user", 500)
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
return domain.UserDTO{}, errors.Wrap(err, "HASH_FAILED", "failed to hash password", 500)
}
status := req.Status
if status == 0 {
status = 1
}
user := &domain.User{
TenantID: tenantID,
Username: req.Username,
Email: req.Email,
Password: string(hash),
Nickname: req.Nickname,
Status: status,
}
if err := s.repo.Create(ctx, user); err != nil {
return domain.UserDTO{}, errors.Wrap(err, "CREATE_USER_FAILED", "failed to create user", 500)
}
return domain.ToUserDTO(user), nil
}
func (s *Service) UpdateUser(ctx context.Context, id uint, req domain.UpdateUserRequest) (domain.UserDTO, error) {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return domain.UserDTO{}, errors.ErrTenantRequired
}
user, err := s.repo.FindByID(ctx, tenantID, id)
if err != nil {
return domain.UserDTO{}, errors.ErrUserNotFound
}
if req.Nickname != "" {
user.Nickname = req.Nickname
}
if req.Email != "" {
user.Email = req.Email
}
if req.Status != nil {
user.Status = *req.Status
}
if err := s.repo.Update(ctx, user); err != nil {
return domain.UserDTO{}, errors.Wrap(err, "UPDATE_USER_FAILED", "failed to update user", 500)
}
return domain.ToUserDTO(user), nil
}
func (s *Service) DeleteUser(ctx context.Context, id uint) error {
tenantID, ok := tenant.FromContext(ctx)
if !ok {
return errors.ErrTenantRequired
}
if _, err := s.repo.FindByID(ctx, tenantID, id); err != nil {
return errors.ErrUserNotFound
}
if err := s.repo.Delete(ctx, tenantID, id); err != nil {
return errors.Wrap(err, "DELETE_USER_FAILED", "failed to delete user", 500)
}
return nil
}
func (s *Service) UserCount(ctx context.Context, tenantID string) (int64, error) {
return s.repo.Count(ctx, tenantID)
}
func (s *Service) UserCountAll(ctx context.Context) (int64, error) {
return s.repo.CountAll(ctx)
}
func (s *Service) ListAllUsers(ctx context.Context, page, pageSize int) ([]domain.UserDTO, int64, error) {
users, total, err := s.repo.ListAll(ctx, page, pageSize)
if err != nil {
return nil, 0, errors.Wrap(err, "LIST_USERS_FAILED", "failed to list users", 500)
}
dtos := make([]domain.UserDTO, len(users))
for i, u := range users {
dtos[i] = domain.ToUserDTO(&u)
}
return dtos, total, nil
}
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,

View File

@ -24,17 +24,18 @@ type TokenPair struct {
}
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"`
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"`
LastLogin string `json:"last_login,omitempty"`
}
func ToUserDTO(u *User) UserDTO {
return UserDTO{
dto := UserDTO{
ID: u.ID,
TenantID: u.TenantID,
Username: u.Username,
@ -43,4 +44,22 @@ func ToUserDTO(u *User) UserDTO {
Avatar: u.Avatar,
Status: u.Status,
}
if u.LastLogin != nil {
dto.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
}
return dto
}
type CreateUserRequest 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"`
Status int `json:"status"`
}
type UpdateUserRequest struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
Status *int `json:"status"`
}

View File

@ -8,4 +8,9 @@ type UserRepository interface {
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
List(ctx context.Context, tenantID string, page, pageSize int, search string, status *int) ([]User, int64, error)
Delete(ctx context.Context, tenantID string, id uint) error
Count(ctx context.Context, tenantID string) (int64, error)
CountAll(ctx context.Context) (int64, error)
ListAll(ctx context.Context, page, pageSize int) ([]User, int64, error)
}

View File

@ -50,3 +50,61 @@ func (r *userRepo) FindByUsername(ctx context.Context, tenantID string, username
func (r *userRepo) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *userRepo) List(ctx context.Context, tenantID string, page, pageSize int, search string, status *int) ([]domain.User, int64, error) {
var users []domain.User
var total int64
q := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID)
if search != "" {
like := "%" + search + "%"
q = q.Where("username LIKE ? OR email LIKE ? OR nickname LIKE ?", like, like, like)
}
if status != nil {
q = q.Where("status = ?", *status)
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *userRepo) Delete(ctx context.Context, tenantID string, id uint) error {
return r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&domain.User{}).Error
}
func (r *userRepo) Count(ctx context.Context, tenantID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error
return count, err
}
func (r *userRepo) CountAll(ctx context.Context) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&count).Error
return count, err
}
func (r *userRepo) ListAll(ctx context.Context, page, pageSize int) ([]domain.User, int64, error) {
var users []domain.User
var total int64
if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := r.db.WithContext(ctx).Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}

View File

@ -1,6 +1,8 @@
package interfaces
import (
"strconv"
"mengstack/internal/kernel/response"
"mengstack/internal/modules/auth/application"
"mengstack/internal/modules/auth/domain"
@ -164,3 +166,143 @@ func (h *Handler) GetProfile(c *gin.Context) {
func (h *Handler) Ping(c *gin.Context) {
response.Success(c, gin.H{"message": "pong"})
}
// ListUsers godoc
// @Summary 用户列表
// @Description 分页获取当前租户下的用户列表,支持搜索和状态过滤
// @Tags Users
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param search query string false "搜索关键词"
// @Param status query int false "状态过滤"
// @Success 200 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /users [get]
func (h *Handler) ListUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
search := c.Query("search")
var status *int
if s := c.Query("status"); s != "" {
v, _ := strconv.Atoi(s)
status = &v
}
dtos, total, err := h.svc.ListUsers(c.Request.Context(), page, pageSize, search, status)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, gin.H{"items": dtos, "total": total, "page": page})
}
// GetUser godoc
// @Summary 获取用户详情
// @Tags Users
// @Produce json
// @Param id path int true "用户 ID"
// @Success 200 {object} response.Response{data=domain.UserDTO}
// @Security Bearer
// @Security TenantID
// @Router /users/{id} [get]
func (h *Handler) GetUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, response.NewBadRequest("invalid user id"))
return
}
dto, err := h.svc.GetUser(c.Request.Context(), uint(id))
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// CreateUser godoc
// @Summary 创建用户
// @Tags Users
// @Accept json
// @Produce json
// @Param request body domain.CreateUserRequest true "用户信息"
// @Success 200 {object} response.Response{data=domain.UserDTO}
// @Security Bearer
// @Security TenantID
// @Router /users [post]
func (h *Handler) CreateUser(c *gin.Context) {
var req domain.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
dto, err := h.svc.CreateUser(c.Request.Context(), req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// UpdateUser godoc
// @Summary 更新用户
// @Tags Users
// @Accept json
// @Produce json
// @Param id path int true "用户 ID"
// @Param request body domain.UpdateUserRequest true "更新内容"
// @Success 200 {object} response.Response{data=domain.UserDTO}
// @Security Bearer
// @Security TenantID
// @Router /users/{id} [put]
func (h *Handler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, response.NewBadRequest("invalid user id"))
return
}
var req domain.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
dto, err := h.svc.UpdateUser(c.Request.Context(), uint(id), req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
// DeleteUser godoc
// @Summary 删除用户
// @Tags Users
// @Param id path int true "用户 ID"
// @Success 200 {object} response.Response
// @Security Bearer
// @Security TenantID
// @Router /users/{id} [delete]
func (h *Handler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Fail(c, response.NewBadRequest("invalid user id"))
return
}
if err := h.svc.DeleteUser(c.Request.Context(), uint(id)); err != nil {
response.HandleError(c, err)
return
}
response.Success(c, nil)
}

View File

@ -3,12 +3,12 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/config"
"mengstack/internal/kernel/tenant"
"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 {
@ -42,9 +42,9 @@ func NewAuthMiddleware(in AuthMiddleware) gin.HandlerFunc {
}
}
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
auth := r.Group("/api/v1/auth")
auth.Use(middleware.OptionalTenant())
auth.Use(middleware.OptionalTenant(tenantResolver))
{
auth.POST("/register", h.Register)
auth.POST("/login", h.Login)
@ -52,11 +52,17 @@ func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
}
protected := r.Group("/api/v1")
protected.Use(authMW, middleware.MultiTenant())
protected.Use(authMW, middleware.MultiTenant(tenantResolver))
{
protected.GET("/ping", h.Ping)
protected.GET("/profile", h.GetProfile)
protected.POST("/password", h.ChangePassword)
protected.GET("/users", h.ListUsers)
protected.POST("/users", h.CreateUser)
protected.GET("/users/:id", h.GetUser)
protected.PUT("/users/:id", h.UpdateUser)
protected.DELETE("/users/:id", h.DeleteUser)
}
}
@ -77,7 +83,4 @@ var Module = fx.Module("auth",
NewHandler,
NewAuthMiddleware,
),
fx.Invoke(func(db *gorm.DB) error {
return infrastructure.Migrate(db)
}),
)

View File

@ -0,0 +1,97 @@
package interfaces
import (
"time"
"mengstack/internal/kernel/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
}
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
type StatsResponse struct {
TenantCount int64 `json:"tenantCount"`
UserCount int64 `json:"userCount"`
RoleCount int64 `json:"roleCount"`
OnlineCount int64 `json:"onlineCount"`
TenantGrowth float64 `json:"tenantGrowth"`
UserGrowth float64 `json:"userGrowth"`
RoleGrowth float64 `json:"roleGrowth"`
TodayActiveUsers int64 `json:"todayActiveUsers"`
}
// GetStats godoc
// @Summary 仪表盘统计
// @Description 返回平台核心指标的统计数据
// @Tags Dashboard
// @Produce json
// @Success 200 {object} response.Response{data=StatsResponse}
// @Security Bearer
// @Security TenantID
// @Router /dashboard/stats [get]
func (h *Handler) GetStats(c *gin.Context) {
tenantID, _ := c.Get("tenant_id")
isSuperAdmin := tenantID == ""
var tenantCount, userCount, roleCount int64
var tenantPrev, userPrev, rolePrev int64
now := time.Now()
thirtyDaysAgo := now.AddDate(0, 0, -30)
sixtyDaysAgo := now.AddDate(0, 0, -60)
if isSuperAdmin {
h.db.Table("tenants").Count(&tenantCount)
h.db.Table("users").Count(&userCount)
h.db.Table("roles").Count(&roleCount)
h.db.Table("tenants").Where("created_at < ? AND created_at >= ?", thirtyDaysAgo, sixtyDaysAgo).Count(&tenantPrev)
h.db.Table("users").Where("created_at < ? AND created_at >= ?", thirtyDaysAgo, sixtyDaysAgo).Count(&userPrev)
h.db.Table("roles").Where("created_at < ? AND created_at >= ?", thirtyDaysAgo, sixtyDaysAgo).Count(&rolePrev)
} else {
h.db.Table("users").Where("tenant_id = ?", tenantID).Count(&userCount)
h.db.Table("roles").Where("tenant_id = ?", tenantID).Count(&roleCount)
h.db.Table("users").Where("tenant_id = ? AND created_at < ? AND created_at >= ?", tenantID, thirtyDaysAgo, sixtyDaysAgo).Count(&userPrev)
h.db.Table("roles").Where("tenant_id = ? AND created_at < ? AND created_at >= ?", tenantID, thirtyDaysAgo, sixtyDaysAgo).Count(&rolePrev)
}
var todayActiveUsers int64
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if isSuperAdmin {
h.db.Table("users").Where("last_login >= ?", todayStart).Count(&todayActiveUsers)
} else {
h.db.Table("users").Where("tenant_id = ? AND last_login >= ?", tenantID, todayStart).Count(&todayActiveUsers)
}
stats := StatsResponse{
TenantCount: tenantCount,
UserCount: userCount,
RoleCount: roleCount,
OnlineCount: 0,
TenantGrowth: calcGrowth(tenantCount, tenantPrev),
UserGrowth: calcGrowth(userCount, userPrev),
RoleGrowth: calcGrowth(roleCount, rolePrev),
TodayActiveUsers: todayActiveUsers,
}
response.Success(c, stats)
}
func calcGrowth(current, previous int64) float64 {
if previous == 0 {
if current > 0 {
return 100
}
return 0
}
return float64(current-previous) / float64(previous) * 100
}

View File

@ -0,0 +1,26 @@
package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"gorm.io/gorm"
)
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
dash := r.Group("/api/v1/dashboard")
dash.Use(authMW, middleware.MultiTenant(tenantResolver))
{
dash.GET("/stats", h.GetStats)
}
}
var Module = fx.Module("dashboard",
fx.Provide(
func(db *gorm.DB) *Handler {
return NewHandler(db)
},
),
)

View File

@ -1,15 +1,16 @@
package interfaces
import (
"io/fs"
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/plugin"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/example/application"
"mengstack/internal/modules/example/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ExamplePlugin implements plugin.Plugin.
@ -32,6 +33,17 @@ func (p *ExamplePlugin) Metadata() plugin.Metadata {
}
}
func (p *ExamplePlugin) Permissions() plugin.Permissions {
return plugin.Permissions{
Database: []string{""}, // full database access (all tables)
Storage: false, // no file storage
Network: []string{}, // no network access
Events: []string{"*"}, // can emit any event
Routes: []string{"/api/v1/examples", "/api/v1/examples/*"},
AdminPanel: false,
}
}
func (p *ExamplePlugin) FxOption() fx.Option {
return fx.Module("example-plugin",
fx.Provide(
@ -39,22 +51,21 @@ func (p *ExamplePlugin) FxOption() fx.Option {
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("example plugin migration failed", zap.Error(err))
}
}),
)
}
func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc) {
g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant())
func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant(tenantResolver))
g.POST("", p.handler.Create)
g.GET("", p.handler.List)
g.GET("/:id", p.handler.Get)
g.DELETE("/:id", p.handler.Delete)
}
func (p *ExamplePlugin) MigrationsFS() fs.FS {
return nil
}
func (p *ExamplePlugin) Init() error {
return nil
}
@ -68,9 +79,4 @@ var Module = fx.Module("example-plugin",
NewHandler,
New,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("example plugin migration failed", zap.Error(err))
}
}),
)

View File

@ -2,22 +2,22 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/notification/application"
"mengstack/internal/modules/notification/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(
r *gin.Engine,
h *Handler,
authMW gin.HandlerFunc,
tenantResolver tenant.Resolver,
) {
notifications := r.Group("/api/v1/notifications")
notifications.Use(authMW, middleware.MultiTenant())
notifications.Use(authMW, middleware.MultiTenant(tenantResolver))
{
notifications.POST("", h.Create)
notifications.GET("/:id", h.Get)
@ -35,9 +35,4 @@ var Module = fx.Module("notification",
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("notification migration failed", zap.Error(err))
}
}),
)

View File

@ -2,18 +2,17 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/org/application"
"mengstack/internal/modules/org/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
org := r.Group("/api/v1/org")
org.Use(authMW, middleware.MultiTenant())
org.Use(authMW, middleware.MultiTenant(tenantResolver))
{
org.POST("/tenants", h.CreateTenant)
org.GET("/tenants", h.ListTenants)
@ -29,9 +28,4 @@ var Module = fx.Module("org",
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("org migration failed", zap.Error(err))
}
}),
)

View File

@ -0,0 +1,53 @@
# RBAC 模块规范
## 模块边界
rbac 模块负责权限控制:角色管理、权限点定义、用户-角色关联、接口级鉴权。
**属于本模块:** 角色、权限点、用户角色关联、权限中间件
**不属于本模块:** 用户实体(→ auth)、组织部门(→ org)
## 文件职责
| 文件 | 层 | 职责 |
|------|---|------|
| `domain/role.go` | 领域 | Role 实体 |
| `domain/permission.go` | 领域 | Permission 实体 |
| `domain/repository.go` | 领域 | RoleRepository/PermissionRepository 接口 |
| `application/service.go` | 应用 | 角色/权限 CRUD 编排 |
| `infrastructure/role_repo.go` | 基础设施 | Role GORM 实现 |
| `infrastructure/permission_repo.go` | 基础设施 | Permission GORM 实现 |
| `infrastructure/user_role_repo.go` | 基础设施 | 用户-角色关联实现 |
| `infrastructure/seed.go` | 基础设施 | 默认角色/权限种子数据 |
| `middleware/permission.go` | 中间件 | 接口级权限校验 |
## 权限模型
```
User ←(N:M)→ Role ←(N:M)→ Permission
├── 菜单权限(menu:xxx)
└── 操作权限(action:xxx)
```
## 接口约定
```
GET /api/v1/roles → 角色列表
POST /api/v1/roles → 创建角色
PUT /api/v1/roles/:id → 更新角色
DELETE /api/v1/roles/:id → 删除角色
POST /api/v1/roles/:id/permissions → 分配权限
GET /api/v1/permissions → 权限点列表
POST /api/v1/users/:id/roles → 分配角色
```
## 数据权限(预留)
五级数据权限范围:全部数据 / 本部门 / 本部门及下属 / 仅本人 / 自定义
当前仅实现接口级鉴权,数据权限待业务模块按需扩展。
## 禁止
- 禁止在 domain 层 import gorm
- 禁止绕过权限中间件直接访问受保护资源
- 禁止硬编码角色名(角色通过 seed 初始化,可被修改)

View File

@ -2,6 +2,7 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/rbac/application"
"mengstack/internal/modules/rbac/infrastructure"
rbacmiddleware "mengstack/internal/modules/rbac/middleware"
@ -16,9 +17,10 @@ func SetupRoutes(
r *gin.Engine,
h *Handler,
authMW gin.HandlerFunc,
tenantResolver tenant.Resolver,
) {
rbac := r.Group("/api/v1/rbac")
rbac.Use(authMW, middleware.MultiTenant())
rbac.Use(authMW, middleware.MultiTenant(tenantResolver))
{
rbac.GET("/permissions", h.ListPermissions)
@ -45,10 +47,7 @@ var Module = fx.Module("rbac",
NewHandler,
rbacmiddleware.NewPermissionMiddleware,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("rbac migration failed", zap.Error(err))
}
fx.Invoke(func(log *zap.Logger, db *gorm.DB) {
if err := infrastructure.Seed(db, log); err != nil {
log.Error("rbac seed failed", zap.Error(err))
}

View File

@ -2,22 +2,22 @@ package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/settings/application"
"mengstack/internal/modules/settings/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(
r *gin.Engine,
h *Handler,
authMW gin.HandlerFunc,
tenantResolver tenant.Resolver,
) {
settings := r.Group("/api/v1/settings")
settings.Use(authMW, middleware.MultiTenant())
settings.Use(authMW, middleware.MultiTenant(tenantResolver))
{
settings.GET("", h.ListGlobalSettings)
settings.GET("/global/:key", h.GetGlobalSetting)
@ -36,9 +36,4 @@ var Module = fx.Module("settings",
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("settings migration failed", zap.Error(err))
}
}),
)

97
internal/testutil/db.go Normal file
View File

@ -0,0 +1,97 @@
package testutil
import (
"fmt"
"os"
"testing"
"mengstack/internal/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestDSN() string {
host := envOr("TEST_DB_HOST", "127.0.0.1")
port := envOr("TEST_DB_PORT", "5432")
user := envOr("TEST_DB_USER", "postgres")
pass := envOr("TEST_DB_PASSWORD", "postgres")
dbname := envOr("TEST_DB_NAME", "mengstack_test")
sslmode := envOr("TEST_DB_SSLMODE", "disable")
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
host, port, user, pass, dbname, sslmode)
}
func NewTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := TestDSN()
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("test database not available: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get underlying db: %v", err)
}
t.Cleanup(func() {
sqlDB.Close()
})
return db
}
func CleanDB(t *testing.T, db *gorm.DB) {
t.Helper()
tables := []string{"user_roles", "role_permissions", "roles", "permissions",
"notifications", "audit_logs", "settings", "examples", "users", "tenants"}
for _, table := range tables {
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
t.Logf("warning: clean table %s: %v", table, err)
}
}
}
func TestConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{Port: 0, Mode: "test"},
Database: config.DatabaseConfig{
Host: envOr("TEST_DB_HOST", "127.0.0.1"),
Port: parseIntEnv("TEST_DB_PORT", 5432),
User: envOr("TEST_DB_USER", "postgres"),
Password: envOr("TEST_DB_PASSWORD", "postgres"),
DBName: envOr("TEST_DB_NAME", "mengstack_test"),
SSLMode: envOr("TEST_DB_SSLMODE", "disable"),
},
JWT: config.JWTConfig{
Secret: "test-secret-key-min-32-chars-long!",
AccessExpiryMinutes: 30,
RefreshExpiryDays: 7,
Issuer: "mengstack-test",
},
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func parseIntEnv(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
var n int
fmt.Sscanf(v, "%d", &n)
if n == 0 {
return fallback
}
return n
}

View File

@ -0,0 +1,73 @@
package testutil
import (
"testing"
"time"
authdomain "mengstack/internal/modules/auth/domain"
orgdomain "mengstack/internal/modules/org/domain"
rbacdomain "mengstack/internal/modules/rbac/domain"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const DefaultTenantID = "00000000-0000-0000-0000-000000000001"
func SeedTenant(t *testing.T, db *gorm.DB) string {
t.Helper()
tenant := orgdomain.Tenant{
ID: DefaultTenantID,
Name: "Test Tenant",
Slug: "test",
}
if err := db.FirstOrCreate(&tenant, orgdomain.Tenant{ID: DefaultTenantID}).Error; err != nil {
t.Fatalf("seed tenant: %v", err)
}
return tenant.ID
}
func SeedUser(t *testing.T, db *gorm.DB, tenantID, email, password string) uint {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := authdomain.User{
TenantID: tenantID,
Username: email,
Email: email,
Password: string(hash),
Nickname: "Test User",
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed_user: %v", err)
}
return user.ID
}
func SeedUserWithRole(t *testing.T, db *gorm.DB, tenantID, email, password string) uint {
t.Helper()
userID := SeedUser(t, db, tenantID, email, password)
role := rbacdomain.Role{
TenantID: tenantID,
Name: "admin",
IsSystem: true,
}
db.FirstOrCreate(&role, rbacdomain.Role{TenantID: tenantID, Name: "admin"})
userRole := rbacdomain.UserRole{
UserID: userID,
TenantID: tenantID,
RoleID: role.ID,
}
db.FirstOrCreate(&userRole, userRole)
return userID
}
func FutureTime(d time.Duration) time.Time {
return time.Now().Add(d)
}

150
internal/testutil/server.go Normal file
View File

@ -0,0 +1,150 @@
package testutil
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
type APITest struct {
t *testing.T
engine *gin.Engine
}
func NewAPITest(t *testing.T, engine *gin.Engine) *APITest {
t.Helper()
return &APITest{t: t, engine: engine}
}
func (a *APITest) Request(method, path string, body interface{}) *APIRequest {
t := a.t
t.Helper()
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
reader = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return &APIRequest{t: t, engine: a.engine, req: req}
}
func (a *APITest) Get(path string) *APIRequest {
return a.Request("GET", path, nil)
}
func (a *APITest) Post(path string, body interface{}) *APIRequest {
return a.Request("POST", path, body)
}
func (a *APITest) Put(path string, body interface{}) *APIRequest {
return a.Request("PUT", path, body)
}
func (a *APITest) Delete(path string) *APIRequest {
return a.Request("DELETE", path, nil)
}
type APIRequest struct {
t *testing.T
engine *gin.Engine
req *http.Request
}
func (r *APIRequest) WithToken(token string) *APIRequest {
r.req.Header.Set("Authorization", "Bearer "+token)
return r
}
func (r *APIRequest) WithTenant(tenantID string) *APIRequest {
r.req.Header.Set("X-Tenant-ID", tenantID)
return r
}
func (r *APIRequest) WithHeader(key, value string) *APIRequest {
r.req.Header.Set(key, value)
return r
}
func (r *APIRequest) Do() *APIResponse {
r.t.Helper()
w := httptest.NewRecorder()
r.engine.ServeHTTP(w, r.req)
resp := &APIResponse{
Code: w.Code,
Headers: w.Header(),
BodyBytes: w.Body.Bytes(),
t: r.t,
}
var result map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &result); err == nil {
resp.JSON = result
}
return resp
}
type APIResponse struct {
Code int
Headers http.Header
BodyBytes []byte
JSON map[string]interface{}
t *testing.T
}
func (r *APIResponse) AssertStatus(expected int) *APIResponse {
r.t.Helper()
if r.Code != expected {
r.t.Errorf("expected status %d, got %d; body: %s", expected, r.Code, string(r.BodyBytes))
}
return r
}
func (r *APIResponse) AssertCode(expected float64) *APIResponse {
r.t.Helper()
if r.JSON == nil {
r.t.Fatal("response is not JSON")
}
got, ok := r.JSON["code"]
if !ok {
r.t.Fatal("response has no 'code' field")
}
if got.(float64) != expected {
r.t.Errorf("expected code %.0f, got %.0f; body: %s", expected, got, string(r.BodyBytes))
}
return r
}
func (r *APIResponse) Data() map[string]interface{} {
r.t.Helper()
if r.JSON == nil {
r.t.Fatal("response is not JSON")
}
data, ok := r.JSON["data"]
if !ok {
r.t.Fatal("response has no 'data' field")
}
m, ok := data.(map[string]interface{})
if !ok {
r.t.Fatalf("data is not an object: %T", data)
}
return m
}

View File

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS permissions;

View File

@ -0,0 +1,36 @@
-- RBAC tables
CREATE TABLE IF NOT EXISTS permissions (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
module VARCHAR(64) NOT NULL,
description VARCHAR(256),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roles (
id BIGSERIAL,
tenant_id UUID NOT NULL,
name VARCHAR(64) NOT NULL,
description VARCHAR(256),
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, tenant_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_role_name ON roles(tenant_id, name);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT NOT NULL,
tenant_id UUID NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, tenant_id)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS audit_logs;

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
resource VARCHAR(128) NOT NULL,
resource_id VARCHAR(128),
detail TEXT,
ip VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_id ON audit_logs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS settings;

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS settings (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL DEFAULT '',
key VARCHAR(128) NOT NULL,
value TEXT,
type VARCHAR(16) NOT NULL DEFAULT 'string',
scope VARCHAR(16) NOT NULL DEFAULT 'tenant',
updated_by BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_key ON settings(tenant_id, key);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS notifications;

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS notifications (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
title VARCHAR(256) NOT NULL,
content TEXT,
type VARCHAR(32) NOT NULL DEFAULT 'info',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_tenant_id ON notifications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS examples;

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS examples (
id BIGSERIAL,
tenant_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
status SMALLINT NOT NULL DEFAULT 1,
expires_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator_id BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_examples_tenant_id ON examples(tenant_id);
CREATE INDEX IF NOT EXISTS idx_examples_deleted_at ON examples(deleted_at);

6
migrations/embed.go Normal file
View File

@ -0,0 +1,6 @@
package migrations
import "embed"
//go:embed *.sql
var Files embed.FS

86
pkg/query/query.go Normal file
View File

@ -0,0 +1,86 @@
package query
import (
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Params struct {
Page int
PageSize int
SortBy string
SortDir string
Filters map[string]string
}
func ParseParams(c *gin.Context) Params {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
sortBy := c.Query("sort_by")
sortDir := c.DefaultQuery("sort_dir", "asc")
if sortDir != "asc" && sortDir != "desc" {
sortDir = "asc"
}
filters := make(map[string]string)
for key, values := range c.Request.URL.Query() {
if key != "page" && key != "page_size" && key != "sort_by" && key != "sort_dir" {
filters[key] = values[0]
}
}
return Params{
Page: page,
PageSize: pageSize,
SortBy: sortBy,
SortDir: sortDir,
Filters: filters,
}
}
func Paginate(p Params) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (p.Page - 1) * p.PageSize
return db.Offset(offset).Limit(p.PageSize)
}
}
func Sort(p Params) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if p.SortBy == "" {
return db
}
return db.Order(p.SortBy + " " + p.SortDir)
}
}
func Filter(p Params, allowed map[string]string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
for key, value := range p.Filters {
if col, ok := allowed[key]; ok && value != "" {
db = db.Where(col+" = ?", value)
}
}
return db
}
}
type PageResult[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}

View File

@ -0,0 +1,106 @@
package benchmarks
import (
"fmt"
"testing"
"time"
"mengstack/internal/modules/auth/application"
"golang.org/x/crypto/bcrypt"
)
func BenchmarkJWT_GenerateToken(b *testing.B) {
secret := "benchmark-secret-key-for-testing-only"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := application.GenerateToken(1, "tenant-1", "access", 15*time.Minute, secret, "mengstack")
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkJWT_ParseToken(b *testing.B) {
secret := "benchmark-secret-key-for-testing-only"
token, err := application.GenerateToken(1, "tenant-1", "access", 15*time.Minute, secret, "mengstack")
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := application.ParseToken(token, secret)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkBcrypt_Hash(b *testing.B) {
password := "benchmark-password-12345"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkBcrypt_Compare(b *testing.B) {
password := "benchmark-password-12345"
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = bcrypt.CompareHashAndPassword(hash, []byte(password))
}
}
func BenchmarkBcrypt_Compare_Wrong(b *testing.B) {
password := "benchmark-password-12345"
wrong := "wrong-password-67890"
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
b.Fatal(err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = bcrypt.CompareHashAndPassword(hash, []byte(wrong))
}
}
func BenchmarkJWT_Throughput(b *testing.B) {
secret := "benchmark-secret-key-for-testing-only"
b.RunParallel(func(pb *testing.PB) {
i := uint(0)
for pb.Next() {
token, err := application.GenerateToken(i, "tenant-1", "access", 15*time.Minute, secret, "mengstack")
if err != nil {
b.Fatal(err)
}
_, err = application.ParseToken(token, secret)
if err != nil {
b.Fatal(err)
}
i++
}
})
}
func BenchmarkBcrypt_CostComparison(b *testing.B) {
password := "benchmark-password-12345"
for _, cost := range []int{10, 12, 14} {
b.Run(fmt.Sprintf("cost_%d", cost), func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
b.Fatal(err)
}
}
})
}
}

View File

@ -0,0 +1,97 @@
package benchmarks
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"mengstack/internal/app/middleware"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func BenchmarkMiddleware_RequestID(b *testing.B) {
r := gin.New()
r.Use(middleware.RequestID())
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
}
func BenchmarkMiddleware_SecurityHeaders(b *testing.B) {
r := gin.New()
r.Use(middleware.SecurityHeaders())
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
}
func BenchmarkMiddleware_CORS(b *testing.B) {
r := gin.New()
r.Use(middleware.CORS())
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
}
func BenchmarkMiddleware_BodyLimit(b *testing.B) {
r := gin.New()
r.Use(middleware.BodyLimit(10 << 20))
r.POST("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/test", nil)
r.ServeHTTP(w, req)
}
}
func BenchmarkMiddleware_FullStack(b *testing.B) {
r := gin.New()
r.Use(middleware.RequestID())
r.Use(middleware.SecurityHeaders())
r.Use(middleware.CORS())
r.Use(middleware.BodyLimit(10 << 20))
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
}
func BenchmarkMiddleware_RateLimit_InMemory(b *testing.B) {
r := gin.New()
r.Use(middleware.RateLimit(1000000, time.Minute))
r.GET("/test", func(c *gin.Context) { c.String(200, "ok") })
b.ResetTimer()
for i := 0; i < b.N; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
}
}

View File

@ -0,0 +1,87 @@
package integration
import (
"testing"
"mengstack/internal/app/middleware"
"mengstack/internal/config"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/auth/application"
authinfra "mengstack/internal/modules/auth/infrastructure"
authinterfaces "mengstack/internal/modules/auth/interfaces"
"mengstack/internal/testutil"
"github.com/gin-gonic/gin"
)
func setupAuthEngine(t *testing.T) (*gin.Engine, *testutil.APITest) {
t.Helper()
db := testutil.NewTestDB(t)
testutil.CleanDB(t, db)
cfg := testutil.TestConfig()
repo := authinfra.NewUserRepository(db)
jwtCfg := application.JWTConfig{
Secret: cfg.JWT.Secret,
AccessExpiryMinutes: cfg.JWT.AccessExpiryMinutes,
RefreshExpiryDays: cfg.JWT.RefreshExpiryDays,
Issuer: cfg.JWT.Issuer,
}
svc := application.NewService(repo, jwtCfg)
handler := authinterfaces.NewHandler(svc)
r := gin.New()
tenantResolver := tenant.NewResolver(config.TenantConfig{Mode: "multi"})
authinterfaces.SetupRoutes(r, handler, middleware.MultiTenant(tenantResolver), tenantResolver)
return r, testutil.NewAPITest(t, r)
}
func TestRegister(t *testing.T) {
db := testutil.NewTestDB(t)
testutil.CleanDB(t, db)
testutil.SeedTenant(t, db)
_, api := setupAuthEngine(t)
api.Post("/api/v1/auth/register", map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "Password123!",
}).WithTenant(testutil.DefaultTenantID).Do().AssertStatus(200).AssertCode(0)
}
func TestLogin(t *testing.T) {
db := testutil.NewTestDB(t)
testutil.CleanDB(t, db)
testutil.SeedTenant(t, db)
testutil.SeedUser(t, db, testutil.DefaultTenantID, "login@test.com", "Password123!")
_, api := setupAuthEngine(t)
resp := api.Post("/api/v1/auth/login", map[string]string{
"email": "login@test.com",
"password": "Password123!",
}).WithTenant(testutil.DefaultTenantID).Do().AssertStatus(200).AssertCode(0)
data := resp.Data()
if data["access_token"] == nil {
t.Error("expected access_token in response")
}
if data["refresh_token"] == nil {
t.Error("expected refresh_token in response")
}
}
func TestLoginWrongPassword(t *testing.T) {
db := testutil.NewTestDB(t)
testutil.CleanDB(t, db)
testutil.SeedTenant(t, db)
testutil.SeedUser(t, db, testutil.DefaultTenantID, "wrong@test.com", "Password123!")
_, api := setupAuthEngine(t)
api.Post("/api/v1/auth/login", map[string]string{
"email": "wrong@test.com",
"password": "WrongPassword!",
}).WithTenant(testutil.DefaultTenantID).Do().AssertStatus(401)
}