feat: user CRUD API + dashboard stats + kernel infrastructure
Some checks failed
CI / Build & Test (push) Failing after 1m31s
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:
parent
70a5e3ffaf
commit
df809f1045
146
AGENTS.md
Normal file
146
AGENTS.md
Normal 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
200
LICENSE
Normal 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
349
README.md
@ -1,44 +1,20 @@
|
|||||||
# MengStack API
|
# MengStack Core
|
||||||
|
|
||||||
MengStack 是一个基于 Go 的企业级后端框架,采用清晰的分层架构和依赖注入,帮助开发者快速构建高质量的多租户 Web 应用。
|
[](https://go.dev)
|
||||||
|
[](https://www.postgresql.org)
|
||||||
|
[](https://redis.io)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
## 技术栈
|
MengStack(软盟开发框架)是一个面向多租户 SaaS 应用的 Go 通用底座。把账号、权限、租户、组织、审计、配置、消息、插件等所有 Web 应用共享的能力一次性做好,让你只专注于业务逻辑。
|
||||||
|
|
||||||
| 组件 | 技术选型 | 说明 |
|
## 为什么选择 MengStack
|
||||||
|------|---------|------|
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 架构设计
|
- **多租户内建** — 共享表 + `tenant_id` 显式过滤,可选 Schema 隔离与 PG 行级安全(RLS)兜底
|
||||||
|
- **插件架构** — 通过扩展点和 `.mengplugin` 包格式扩展功能,不改动内核代码
|
||||||
### 三层隔离
|
- **四层分层** — domain → application → infrastructure → interfaces,职责清晰、可测试
|
||||||
|
- **编译期依赖注入** — 基于 Uber fx,依赖错误在编译时暴露,不是运行时
|
||||||
```
|
- **安全默认** — JWT 双 Token、RBAC 权限、bcrypt 密码、参数化查询、XSS 过滤、三级限流
|
||||||
cmd/server/ → 启动层:组装依赖,启动服务
|
- **可观测性** — Zap 结构化日志(含 trace_id / tenant_id)、健康检查、就绪探针
|
||||||
internal/app/ → 基础设施层:数据库、缓存、中间件、健康检查
|
|
||||||
internal/modules/ → 业务模块层:按领域划分的独立模块
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模块四层结构
|
|
||||||
|
|
||||||
每个业务模块遵循统一的四层架构:
|
|
||||||
|
|
||||||
```
|
|
||||||
modules/auth/
|
|
||||||
├── domain/ → 领域层:实体、DTO、仓储接口
|
|
||||||
├── application/ → 应用层:业务逻辑、服务实现
|
|
||||||
├── infrastructure/ → 基础设施层:仓储实现、数据库迁移
|
|
||||||
└── interfaces/ → 接口层:HTTP Handler、路由注册
|
|
||||||
```
|
|
||||||
|
|
||||||
### 依赖注入
|
|
||||||
|
|
||||||
使用 `uber/fx` 管理依赖,每个模块通过 `fx.Provide` 注册,框架自动解析依赖图。生命周期钩子(`fx.Lifecycle`)管理服务器启停,禁止在 Provider 中启动 goroutine。
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@ -48,161 +24,232 @@ modules/auth/
|
|||||||
- PostgreSQL 16+
|
- PostgreSQL 16+
|
||||||
- Redis 7+
|
- Redis 7+
|
||||||
|
|
||||||
### 方式一:Docker Compose(推荐)
|
### 1. 克隆项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆项目
|
git clone https://github.com/mengstack/core.git
|
||||||
git clone https://mengstackgit.softunis.com/softunis/mengstack-api.git
|
cd core
|
||||||
cd mengstack-api
|
|
||||||
|
|
||||||
# 复制环境变量并修改
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# 一键启动(PostgreSQL + Redis + API)
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose logs -f api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式二:本地开发
|
### 2. 配置环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 启动 PostgreSQL 和 Redis(可用 Docker)
|
|
||||||
docker-compose up -d postgres redis
|
|
||||||
|
|
||||||
# 2. 复制并编辑配置
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env 填入数据库密码等
|
# 编辑 .env,填入数据库密码和 JWT 密钥
|
||||||
|
```
|
||||||
|
|
||||||
# 3. 安装依赖
|
### 3. 启动依赖服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# 4. 启动服务
|
# 运行迁移 + 启动
|
||||||
make run
|
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/ # 应用入口
|
├── cmd/
|
||||||
├── configs/ # 配置模板
|
│ ├── server/ # 主服务入口
|
||||||
│ ├── config.yaml # 基础配置
|
│ ├── migrate/ # 数据库迁移工具
|
||||||
│ ├── config.dev.yaml # 开发环境覆盖
|
│ └── smrm/ # CLI 脚手架工具
|
||||||
│ └── config.prod.yaml # 生产环境覆盖
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── app/ # 基础设施
|
│ ├── app/ # 应用基础设施
|
||||||
│ │ ├── cache/ # Redis 模块
|
│ │ ├── cache/ # Redis 连接
|
||||||
│ │ ├── database/ # PostgreSQL 模块
|
│ │ ├── database/ # PostgreSQL 连接
|
||||||
│ │ ├── health/ # 健康检查
|
│ │ ├── health/ # 健康检查 & 就绪探针
|
||||||
│ │ └── middleware/ # 请求ID、多租户中间件
|
│ │ ├── middleware/ # 请求 ID、认证、多租户、i18n
|
||||||
│ ├── config/ # 配置加载器
|
│ │ ├── migrate/ # 迁移执行器
|
||||||
│ ├── kernel/ # 核心工具(错误、响应、租户上下文)
|
│ │ └── upgrade/ # 在线升级预留
|
||||||
│ ├── logger/ # Zap 日志
|
│ ├── config/ # Viper 配置加载
|
||||||
|
│ ├── kernel/ # ★ 内核(开源部分)
|
||||||
|
│ │ ├── errors/ # 统一错误码
|
||||||
|
│ │ ├── response/ # 统一响应格式
|
||||||
|
│ │ ├── tenant/ # 多租户抽象
|
||||||
|
│ │ ├── model/ # 公共基础实体
|
||||||
|
│ │ ├── plugin/ # 插件接口 & 沙箱
|
||||||
|
│ │ ├── eventbus/ # 事件总线
|
||||||
|
│ │ ├── storage/ # 存储抽象
|
||||||
|
│ │ ├── cache/ # 缓存工具
|
||||||
|
│ │ ├── jobs/ # 后台任务调度
|
||||||
|
│ │ ├── i18n/ # 国际化框架
|
||||||
|
│ │ └── websocket/ # WebSocket 推送
|
||||||
|
│ ├── logger/ # Zap 结构化日志
|
||||||
│ └── modules/ # 业务模块
|
│ └── modules/ # 业务模块
|
||||||
│ └── auth/ # 认证模块(注册/登录/刷新Token)
|
│ ├── auth/ # 认证(注册/登录/刷新)
|
||||||
├── migrations/ # 数据库迁移脚本
|
│ ├── rbac/ # 角色权限
|
||||||
├── docs/ # Swagger 文档
|
│ ├── org/ # 组织架构
|
||||||
├── assets/ # 静态资源(Logo 等)
|
│ ├── audit/ # 审计日志
|
||||||
├── .env.example # 环境变量模板
|
│ ├── settings/ # 配置管理
|
||||||
├── docker-compose.yml # 容器编排
|
│ ├── notification/# 消息通知
|
||||||
├── Makefile # 常用命令
|
│ └── example/ # 示例插件
|
||||||
└── go.mod # Go 模块定义
|
├── migrations/ # 核心数据库迁移脚本
|
||||||
|
├── configs/ # 分层配置模板
|
||||||
|
├── docs/ # Swagger 生成文档
|
||||||
|
├── test/ # 测试工具
|
||||||
|
├── AGENTS.md # AI 辅助开发规范
|
||||||
|
└── Makefile # 常用命令
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置系统
|
## 核心能力
|
||||||
|
|
||||||
MengStack 采用三层配置覆盖机制,优先级从低到高:
|
### 多租户
|
||||||
|
|
||||||
1. **基础配置** `configs/config.yaml` — 所有环境的默认值
|
```go
|
||||||
2. **环境覆盖** `configs/config.{mode}.yaml` — 按 `server.mode` 加载(dev/prod)
|
// 请求头传入租户 ID
|
||||||
3. **环境变量** `MENGSTACK_` 前缀 — 最高优先级,适合存放密码等敏感信息
|
// 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
|
```bash
|
||||||
# 环境变量命名规则:MENGSTACK_{Section}_{Key}
|
go run ./cmd/smrm new module order
|
||||||
# 例如:
|
|
||||||
MENGSTACK_SERVER_PORT=8080
|
|
||||||
MENGSTACK_DATABASE_PASSWORD=your-secret
|
|
||||||
MENGSTACK_JWT_SECRET=your-jwt-secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**安全提示**:永远不要将真实密码提交到 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
|
```bash
|
||||||
make build # 编译二进制
|
make build # 编译
|
||||||
make run # 启动开发服务
|
make run # 启动开发服务
|
||||||
make test # 运行测试
|
make test # 运行测试
|
||||||
make test-cover # 测试覆盖率报告
|
make test-cover # 测试覆盖率
|
||||||
make lint # 代码检查
|
make lint # 代码检查
|
||||||
make swagger # 重新生成 Swagger 文档
|
make swagger # 重新生成 Swagger 文档
|
||||||
|
make migrate # 运行数据库迁移
|
||||||
make docker-up # Docker 启动全部服务
|
make docker-up # Docker 启动全部服务
|
||||||
make docker-down # 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
|
- API:https://mengstackdemo.softunis.com
|
||||||
- Swagger 文档:https://mengstackdemo.softunis.com/swagger/index.html
|
- 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
107
cmd/migrate/main.go
Normal 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
442
cmd/smrm/main.go
Normal 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
`
|
||||||
@ -24,3 +24,7 @@ jwt:
|
|||||||
log:
|
log:
|
||||||
level: info
|
level: info
|
||||||
format: json
|
format: json
|
||||||
|
|
||||||
|
tenant:
|
||||||
|
mode: multi
|
||||||
|
default_id: "00000000-0000-0000-0000-000000000001"
|
||||||
|
|||||||
824
docs/docs.go
824
docs/docs.go
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
32
go.mod
@ -1,33 +1,39 @@
|
|||||||
module mengstack
|
module mengstack
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.11
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
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-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.20.1
|
||||||
github.com/google/uuid v1.6.0
|
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/redis/go-redis/v9 v9.7.0
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.19.0
|
||||||
go.uber.org/fx v1.23.0
|
go.uber.org/fx v1.23.0
|
||||||
go.uber.org/zap v1.27.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/driver/postgres v1.5.9
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // 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/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.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/dig v1.18.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
117
go.sum
117
go.sum
@ -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 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
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 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 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 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 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-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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
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.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/go-openapi/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/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 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/mailru/easyjson v0.0.0-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/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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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/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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
github.com/sagikazarmark/locafero v0.4.0 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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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.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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
github.com/swaggo/files v1.0.1 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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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=
|
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 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw=
|
||||||
go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg=
|
go.uber.org/fx v1.23.0 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/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-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.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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
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 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
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.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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
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-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-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-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.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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
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-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.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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
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-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.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.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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
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=
|
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.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
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 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -10,15 +10,23 @@ import (
|
|||||||
"mengstack/internal/app/database"
|
"mengstack/internal/app/database"
|
||||||
"mengstack/internal/app/health"
|
"mengstack/internal/app/health"
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
appmigrate "mengstack/internal/app/migrate"
|
||||||
|
"mengstack/internal/app/upgrade"
|
||||||
"mengstack/internal/config"
|
"mengstack/internal/config"
|
||||||
|
"mengstack/internal/kernel/i18n"
|
||||||
"mengstack/internal/kernel/plugin"
|
"mengstack/internal/kernel/plugin"
|
||||||
|
"mengstack/internal/kernel/jobs"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
|
kws "mengstack/internal/kernel/websocket"
|
||||||
"mengstack/internal/logger"
|
"mengstack/internal/logger"
|
||||||
|
"mengstack/migrations"
|
||||||
authinterfaces "mengstack/internal/modules/auth/interfaces"
|
authinterfaces "mengstack/internal/modules/auth/interfaces"
|
||||||
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
|
rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
|
||||||
orginterfaces "mengstack/internal/modules/org/interfaces"
|
orginterfaces "mengstack/internal/modules/org/interfaces"
|
||||||
auditinterfaces "mengstack/internal/modules/audit/interfaces"
|
auditinterfaces "mengstack/internal/modules/audit/interfaces"
|
||||||
settingsinterfaces "mengstack/internal/modules/settings/interfaces"
|
settingsinterfaces "mengstack/internal/modules/settings/interfaces"
|
||||||
notificationinterfaces "mengstack/internal/modules/notification/interfaces"
|
notificationinterfaces "mengstack/internal/modules/notification/interfaces"
|
||||||
|
dashboardinterfaces "mengstack/internal/modules/dashboard/interfaces"
|
||||||
exampleinterfaces "mengstack/internal/modules/example/interfaces"
|
exampleinterfaces "mengstack/internal/modules/example/interfaces"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -32,8 +40,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Module = fx.Module("app",
|
var Module = fx.Module("app",
|
||||||
fx.Provide(newEngine),
|
fx.Provide(
|
||||||
fx.Provide(newServer),
|
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),
|
fx.Invoke(registerLifecycle),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,7 +68,9 @@ func newEngine(
|
|||||||
auditHandler *auditinterfaces.Handler,
|
auditHandler *auditinterfaces.Handler,
|
||||||
settingsHandler *settingsinterfaces.Handler,
|
settingsHandler *settingsinterfaces.Handler,
|
||||||
notificationHandler *notificationinterfaces.Handler,
|
notificationHandler *notificationinterfaces.Handler,
|
||||||
|
dashboardHandler *dashboardinterfaces.Handler,
|
||||||
examplePlugin *exampleinterfaces.ExamplePlugin,
|
examplePlugin *exampleinterfaces.ExamplePlugin,
|
||||||
|
wsHandler *kws.Handler,
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
ginMode := "release"
|
ginMode := "release"
|
||||||
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
|
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
|
||||||
@ -62,23 +83,35 @@ func newEngine(
|
|||||||
r.Use(middleware.RequestLogger(log))
|
r.Use(middleware.RequestLogger(log))
|
||||||
r.Use(middleware.SecurityHeaders())
|
r.Use(middleware.SecurityHeaders())
|
||||||
r.Use(middleware.CORS())
|
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.BodyLimit(10 << 20))
|
||||||
r.Use(middleware.Recovery(log))
|
r.Use(middleware.Recovery(log))
|
||||||
|
|
||||||
healthHandler := health.NewHandler(db, rdb, log)
|
healthHandler := health.NewHandler(db, rdb, log)
|
||||||
r.GET("/health", healthHandler.Handle)
|
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))
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
authinterfaces.SetupRoutes(r, authHandler, authMW)
|
tenantResolver := tenant.NewResolver(cfg.Tenant)
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
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{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sched.Start()
|
||||||
|
|
||||||
log.Info("server starting", zap.String("addr", srv.Addr))
|
log.Info("server starting", zap.String("addr", srv.Addr))
|
||||||
go func() {
|
go func() {
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
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)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
log.Info("server shutting down")
|
log.Info("server shutting down")
|
||||||
|
if err := sched.Stop(); err != nil {
|
||||||
|
log.Error("scheduler stop error", zap.Error(err))
|
||||||
|
}
|
||||||
return srv.Shutdown(shutdownCtx)
|
return srv.Shutdown(shutdownCtx)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -130,6 +192,7 @@ func NewApp() *fx.App {
|
|||||||
auditinterfaces.Module,
|
auditinterfaces.Module,
|
||||||
settingsinterfaces.Module,
|
settingsinterfaces.Module,
|
||||||
notificationinterfaces.Module,
|
notificationinterfaces.Module,
|
||||||
|
dashboardinterfaces.Module,
|
||||||
exampleinterfaces.Module,
|
exampleinterfaces.Module,
|
||||||
Module,
|
Module,
|
||||||
)
|
)
|
||||||
|
|||||||
12
internal/app/cache/module.go
vendored
12
internal/app/cache/module.go
vendored
@ -2,13 +2,19 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/config"
|
"mengstack/internal/config"
|
||||||
|
"mengstack/internal/kernel/cache"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Module = fx.Module("cache",
|
var Module = fx.Module("cache",
|
||||||
fx.Provide(func(cfg *config.Config) (*redis.Client, error) {
|
fx.Provide(
|
||||||
return NewRedis(cfg.Redis)
|
func(cfg *config.Config) (*redis.Client, error) {
|
||||||
}),
|
return NewRedis(cfg.Redis)
|
||||||
|
},
|
||||||
|
func(rdb *redis.Client) *cache.Store {
|
||||||
|
return cache.NewStore(rdb, "mengstack")
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mengstack/internal/app/version"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@ -51,7 +53,71 @@ func (h *Handler) Handle(c *gin.Context) {
|
|||||||
"database": dbStatus,
|
"database": dbStatus,
|
||||||
"redis": redisStatus,
|
"redis": redisStatus,
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp": time.Now().Unix(),
|
||||||
"version": "0.1.0",
|
"version": version.Version,
|
||||||
"trace_id": c.GetString("trace_id"),
|
"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"
|
||||||
|
}
|
||||||
|
|||||||
17
internal/app/middleware/i18n.go
Normal file
17
internal/app/middleware/i18n.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/app/middleware/ratelimit_redis.go
Normal file
111
internal/app/middleware/ratelimit_redis.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,20 +3,20 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"mengstack/internal/kernel/response"
|
|
||||||
"mengstack/internal/kernel/errors"
|
"mengstack/internal/kernel/errors"
|
||||||
|
"mengstack/internal/kernel/response"
|
||||||
"mengstack/internal/kernel/tenant"
|
"mengstack/internal/kernel/tenant"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MultiTenant() gin.HandlerFunc {
|
func MultiTenant(resolver tenant.Resolver) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
tenantID := c.GetHeader("X-Tenant-ID")
|
headerVal := c.GetHeader("X-Tenant-ID")
|
||||||
if tenantID == "" {
|
queryVal := c.Query("tenant_id")
|
||||||
tenantID = c.Query("tenant_id")
|
|
||||||
}
|
tenantID, ok := resolver.Resolve(headerVal, queryVal)
|
||||||
if tenantID == "" {
|
if !ok {
|
||||||
response.Fail(c, errors.ErrTenantRequired)
|
response.Fail(c, errors.ErrTenantRequired)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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) {
|
return func(c *gin.Context) {
|
||||||
tenantID := c.GetHeader("X-Tenant-ID")
|
headerVal := c.GetHeader("X-Tenant-ID")
|
||||||
if tenantID == "" {
|
queryVal := c.Query("tenant_id")
|
||||||
tenantID = c.Query("tenant_id")
|
|
||||||
}
|
tenantID, ok := resolver.Resolve(headerVal, queryVal)
|
||||||
if tenantID != "" {
|
if ok {
|
||||||
c.Set("tenant_id", tenantID)
|
c.Set("tenant_id", tenantID)
|
||||||
ctx := tenant.WithTenantID(c.Request.Context(), tenantID)
|
ctx := tenant.WithTenantID(c.Request.Context(), tenantID)
|
||||||
c.Request = c.Request.WithContext(ctx)
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
|||||||
53
internal/app/migrate/migrate.go
Normal file
53
internal/app/migrate/migrate.go
Normal 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
|
||||||
|
}
|
||||||
55
internal/app/upgrade/README.md
Normal file
55
internal/app/upgrade/README.md
Normal 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
47
internal/app/upgrade/handler.go
Normal file
47
internal/app/upgrade/handler.go
Normal 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)
|
||||||
|
}
|
||||||
32
internal/app/upgrade/upgrade.go
Normal file
32
internal/app/upgrade/upgrade.go
Normal 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)
|
||||||
|
}
|
||||||
29
internal/app/version/version.go
Normal file
29
internal/app/version/version.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@ type Config struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
Plugin PluginConfig `mapstructure:"plugin"`
|
Plugin PluginConfig `mapstructure:"plugin"`
|
||||||
|
Tenant TenantConfig `mapstructure:"tenant"`
|
||||||
|
Storage StorageConfig `mapstructure:"storage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@ -53,6 +55,17 @@ type PluginConfig struct {
|
|||||||
Enabled []string `mapstructure:"enabled"`
|
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) {
|
func Load() (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
v.SetConfigName("config")
|
v.SetConfigName("config")
|
||||||
@ -100,6 +113,11 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("jwt.issuer", "mengstack")
|
v.SetDefault("jwt.issuer", "mengstack")
|
||||||
v.SetDefault("log.level", "info")
|
v.SetDefault("log.level", "info")
|
||||||
v.SetDefault("log.format", "json")
|
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) {
|
func mergeEnvConfig(v *viper.Viper, env string) {
|
||||||
|
|||||||
101
internal/kernel/cache/cache.go
vendored
Normal file
101
internal/kernel/cache/cache.go
vendored
Normal 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
|
||||||
|
}
|
||||||
73
internal/kernel/eventbus/bus.go
Normal file
73
internal/kernel/eventbus/bus.go
Normal 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)
|
||||||
|
}
|
||||||
80
internal/kernel/i18n/context.go
Normal file
80
internal/kernel/i18n/context.go
Normal 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
|
||||||
|
}
|
||||||
6
internal/kernel/i18n/embed.go
Normal file
6
internal/kernel/i18n/embed.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed messages/*.json
|
||||||
|
var Files embed.FS
|
||||||
81
internal/kernel/i18n/i18n.go
Normal file
81
internal/kernel/i18n/i18n.go
Normal 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
|
||||||
|
}
|
||||||
13
internal/kernel/i18n/messages/en.json
Normal file
13
internal/kernel/i18n/messages/en.json
Normal 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"
|
||||||
|
}
|
||||||
13
internal/kernel/i18n/messages/zh.json
Normal file
13
internal/kernel/i18n/messages/zh.json
Normal 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": "无效的令牌"
|
||||||
|
}
|
||||||
100
internal/kernel/jobs/scheduler.go
Normal file
100
internal/kernel/jobs/scheduler.go
Normal 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()
|
||||||
|
}
|
||||||
96
internal/kernel/plugin/.spec/mengplugin-format.md
Normal file
96
internal/kernel/plugin/.spec/mengplugin-format.md
Normal 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 非对称签名
|
||||||
61
internal/kernel/plugin/.spec/module.md
Normal file
61
internal/kernel/plugin/.spec/module.md
Normal 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 任何插件代码
|
||||||
60
internal/kernel/plugin/manifest.go
Normal file
60
internal/kernel/plugin/manifest.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -15,8 +15,11 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata describes a plugin's identity.
|
// Metadata describes a plugin's identity.
|
||||||
@ -31,16 +34,25 @@ type Plugin interface {
|
|||||||
// Metadata returns the plugin's identity.
|
// Metadata returns the plugin's identity.
|
||||||
Metadata() Metadata
|
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 returns the fx.Module for dependency injection.
|
||||||
FxOption() fx.Option
|
FxOption() fx.Option
|
||||||
|
|
||||||
// SetupRoutes registers HTTP routes on the Gin engine.
|
// SetupRoutes registers HTTP routes on the Gin engine.
|
||||||
// authMW is the JWT authentication middleware.
|
// 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
|
// Init is called after all dependencies are resolved and before
|
||||||
// the HTTP server starts. Use it for background workers, warm-up
|
// the HTTP server starts. Use it for background workers, warm-up
|
||||||
// caches, or external service connections.
|
// caches, or external service connections.
|
||||||
// Returning nil indicates success.
|
// Returning nil indicates success.
|
||||||
Init() error
|
Init() error
|
||||||
|
|
||||||
|
// MigrationsFS returns the plugin's embedded migration SQL files.
|
||||||
|
// Return nil if the plugin has no migrations.
|
||||||
|
MigrationsFS() fs.FS
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,14 +30,20 @@ func All() []Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InitAll initializes all registered plugins in order.
|
// 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() {
|
for _, p := range All() {
|
||||||
meta := p.Metadata()
|
meta := p.Metadata()
|
||||||
|
perms := p.Permissions()
|
||||||
|
|
||||||
|
sandbox.Register(meta.Name, perms)
|
||||||
|
|
||||||
log.Info("initializing plugin",
|
log.Info("initializing plugin",
|
||||||
zap.String("name", meta.Name),
|
zap.String("name", meta.Name),
|
||||||
zap.String("version", meta.Version),
|
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)
|
return fmt.Errorf("plugin %s init failed: %w", meta.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
internal/kernel/plugin/sandbox.go
Normal file
212
internal/kernel/plugin/sandbox.go
Normal 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
|
||||||
|
}
|
||||||
191
internal/kernel/plugin/sandbox_test.go
Normal file
191
internal/kernel/plugin/sandbox_test.go
Normal 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 }
|
||||||
85
internal/kernel/storage/local.go
Normal file
85
internal/kernel/storage/local.go
Normal 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
|
||||||
|
}
|
||||||
26
internal/kernel/storage/store.go
Normal file
26
internal/kernel/storage/store.go
Normal 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
|
||||||
|
}
|
||||||
71
internal/kernel/storage/upload.go
Normal file
71
internal/kernel/storage/upload.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/kernel/tenant/resolver.go
Normal file
43
internal/kernel/tenant/resolver.go
Normal 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
|
||||||
|
}
|
||||||
12
internal/kernel/tenant/scope.go
Normal file
12
internal/kernel/tenant/scope.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/kernel/websocket/handler.go
Normal file
102
internal/kernel/websocket/handler.go
Normal 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)
|
||||||
|
}
|
||||||
147
internal/kernel/websocket/hub.go
Normal file
147
internal/kernel/websocket/hub.go
Normal 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
|
||||||
|
)
|
||||||
@ -2,18 +2,17 @@ package interfaces
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/audit/application"
|
"mengstack/internal/modules/audit/application"
|
||||||
auditinfra "mengstack/internal/modules/audit/infrastructure"
|
auditinfra "mengstack/internal/modules/audit/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"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 := r.Group("/api/v1/audit")
|
||||||
audit.Use(authMW, middleware.MultiTenant())
|
audit.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
audit.GET("/logs", h.ListLogs)
|
audit.GET("/logs", h.ListLogs)
|
||||||
}
|
}
|
||||||
@ -25,9 +24,4 @@ var Module = fx.Module("audit",
|
|||||||
application.NewService,
|
application.NewService,
|
||||||
NewHandler,
|
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))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
46
internal/modules/auth/.spec/module.md
Normal file
46
internal/modules/auth/.spec/module.md
Normal 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
|
||||||
@ -155,6 +155,142 @@ func (s *Service) JWTSecret() string {
|
|||||||
return s.jwtCfg.Secret
|
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) {
|
func (s *Service) generateTokens(user *domain.User) (domain.TokenPair, error) {
|
||||||
access, err := GenerateToken(user.ID, user.TenantID, "access",
|
access, err := GenerateToken(user.ID, user.TenantID, "access",
|
||||||
time.Duration(s.jwtCfg.AccessExpiryMinutes)*time.Minute,
|
time.Duration(s.jwtCfg.AccessExpiryMinutes)*time.Minute,
|
||||||
|
|||||||
@ -24,17 +24,18 @@ type TokenPair struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
|
LastLogin string `json:"last_login,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToUserDTO(u *User) UserDTO {
|
func ToUserDTO(u *User) UserDTO {
|
||||||
return UserDTO{
|
dto := UserDTO{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
TenantID: u.TenantID,
|
TenantID: u.TenantID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
@ -43,4 +44,22 @@ func ToUserDTO(u *User) UserDTO {
|
|||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
Status: u.Status,
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,9 @@ type UserRepository interface {
|
|||||||
FindByEmail(ctx context.Context, tenantID string, email string) (*User, error)
|
FindByEmail(ctx context.Context, tenantID string, email string) (*User, error)
|
||||||
FindByUsername(ctx context.Context, tenantID string, username string) (*User, error)
|
FindByUsername(ctx context.Context, tenantID string, username string) (*User, error)
|
||||||
Update(ctx context.Context, user *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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
func (r *userRepo) Update(ctx context.Context, user *domain.User) error {
|
||||||
return r.db.WithContext(ctx).Save(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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package interfaces
|
package interfaces
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"mengstack/internal/kernel/response"
|
"mengstack/internal/kernel/response"
|
||||||
"mengstack/internal/modules/auth/application"
|
"mengstack/internal/modules/auth/application"
|
||||||
"mengstack/internal/modules/auth/domain"
|
"mengstack/internal/modules/auth/domain"
|
||||||
@ -164,3 +166,143 @@ func (h *Handler) GetProfile(c *gin.Context) {
|
|||||||
func (h *Handler) Ping(c *gin.Context) {
|
func (h *Handler) Ping(c *gin.Context) {
|
||||||
response.Success(c, gin.H{"message": "pong"})
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -3,12 +3,12 @@ package interfaces
|
|||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
"mengstack/internal/config"
|
"mengstack/internal/config"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/auth/application"
|
"mengstack/internal/modules/auth/application"
|
||||||
"mengstack/internal/modules/auth/infrastructure"
|
"mengstack/internal/modules/auth/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthMiddleware struct {
|
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 := r.Group("/api/v1/auth")
|
||||||
auth.Use(middleware.OptionalTenant())
|
auth.Use(middleware.OptionalTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
auth.POST("/register", h.Register)
|
auth.POST("/register", h.Register)
|
||||||
auth.POST("/login", h.Login)
|
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 := r.Group("/api/v1")
|
||||||
protected.Use(authMW, middleware.MultiTenant())
|
protected.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
protected.GET("/ping", h.Ping)
|
protected.GET("/ping", h.Ping)
|
||||||
protected.GET("/profile", h.GetProfile)
|
protected.GET("/profile", h.GetProfile)
|
||||||
protected.POST("/password", h.ChangePassword)
|
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,
|
NewHandler,
|
||||||
NewAuthMiddleware,
|
NewAuthMiddleware,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(db *gorm.DB) error {
|
|
||||||
return infrastructure.Migrate(db)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
97
internal/modules/dashboard/interfaces/handler.go
Normal file
97
internal/modules/dashboard/interfaces/handler.go
Normal 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
|
||||||
|
}
|
||||||
26
internal/modules/dashboard/interfaces/routes.go
Normal file
26
internal/modules/dashboard/interfaces/routes.go
Normal 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)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -1,15 +1,16 @@
|
|||||||
package interfaces
|
package interfaces
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
"mengstack/internal/kernel/plugin"
|
"mengstack/internal/kernel/plugin"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/example/application"
|
"mengstack/internal/modules/example/application"
|
||||||
"mengstack/internal/modules/example/infrastructure"
|
"mengstack/internal/modules/example/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExamplePlugin implements plugin.Plugin.
|
// 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 {
|
func (p *ExamplePlugin) FxOption() fx.Option {
|
||||||
return fx.Module("example-plugin",
|
return fx.Module("example-plugin",
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
@ -39,22 +51,21 @@ func (p *ExamplePlugin) FxOption() fx.Option {
|
|||||||
application.NewService,
|
application.NewService,
|
||||||
NewHandler,
|
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) {
|
func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) {
|
||||||
g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant())
|
g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant(tenantResolver))
|
||||||
g.POST("", p.handler.Create)
|
g.POST("", p.handler.Create)
|
||||||
g.GET("", p.handler.List)
|
g.GET("", p.handler.List)
|
||||||
g.GET("/:id", p.handler.Get)
|
g.GET("/:id", p.handler.Get)
|
||||||
g.DELETE("/:id", p.handler.Delete)
|
g.DELETE("/:id", p.handler.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ExamplePlugin) MigrationsFS() fs.FS {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ExamplePlugin) Init() error {
|
func (p *ExamplePlugin) Init() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -68,9 +79,4 @@ var Module = fx.Module("example-plugin",
|
|||||||
NewHandler,
|
NewHandler,
|
||||||
New,
|
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))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,22 +2,22 @@ package interfaces
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/notification/application"
|
"mengstack/internal/modules/notification/application"
|
||||||
"mengstack/internal/modules/notification/infrastructure"
|
"mengstack/internal/modules/notification/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
r *gin.Engine,
|
r *gin.Engine,
|
||||||
h *Handler,
|
h *Handler,
|
||||||
authMW gin.HandlerFunc,
|
authMW gin.HandlerFunc,
|
||||||
|
tenantResolver tenant.Resolver,
|
||||||
) {
|
) {
|
||||||
notifications := r.Group("/api/v1/notifications")
|
notifications := r.Group("/api/v1/notifications")
|
||||||
notifications.Use(authMW, middleware.MultiTenant())
|
notifications.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
notifications.POST("", h.Create)
|
notifications.POST("", h.Create)
|
||||||
notifications.GET("/:id", h.Get)
|
notifications.GET("/:id", h.Get)
|
||||||
@ -35,9 +35,4 @@ var Module = fx.Module("notification",
|
|||||||
application.NewService,
|
application.NewService,
|
||||||
NewHandler,
|
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))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,18 +2,17 @@ package interfaces
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/org/application"
|
"mengstack/internal/modules/org/application"
|
||||||
"mengstack/internal/modules/org/infrastructure"
|
"mengstack/internal/modules/org/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"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 := r.Group("/api/v1/org")
|
||||||
org.Use(authMW, middleware.MultiTenant())
|
org.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
org.POST("/tenants", h.CreateTenant)
|
org.POST("/tenants", h.CreateTenant)
|
||||||
org.GET("/tenants", h.ListTenants)
|
org.GET("/tenants", h.ListTenants)
|
||||||
@ -29,9 +28,4 @@ var Module = fx.Module("org",
|
|||||||
application.NewService,
|
application.NewService,
|
||||||
NewHandler,
|
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))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|||||||
53
internal/modules/rbac/.spec/module.md
Normal file
53
internal/modules/rbac/.spec/module.md
Normal 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 初始化,可被修改)
|
||||||
@ -2,6 +2,7 @@ package interfaces
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/rbac/application"
|
"mengstack/internal/modules/rbac/application"
|
||||||
"mengstack/internal/modules/rbac/infrastructure"
|
"mengstack/internal/modules/rbac/infrastructure"
|
||||||
rbacmiddleware "mengstack/internal/modules/rbac/middleware"
|
rbacmiddleware "mengstack/internal/modules/rbac/middleware"
|
||||||
@ -16,9 +17,10 @@ func SetupRoutes(
|
|||||||
r *gin.Engine,
|
r *gin.Engine,
|
||||||
h *Handler,
|
h *Handler,
|
||||||
authMW gin.HandlerFunc,
|
authMW gin.HandlerFunc,
|
||||||
|
tenantResolver tenant.Resolver,
|
||||||
) {
|
) {
|
||||||
rbac := r.Group("/api/v1/rbac")
|
rbac := r.Group("/api/v1/rbac")
|
||||||
rbac.Use(authMW, middleware.MultiTenant())
|
rbac.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
rbac.GET("/permissions", h.ListPermissions)
|
rbac.GET("/permissions", h.ListPermissions)
|
||||||
|
|
||||||
@ -45,10 +47,7 @@ var Module = fx.Module("rbac",
|
|||||||
NewHandler,
|
NewHandler,
|
||||||
rbacmiddleware.NewPermissionMiddleware,
|
rbacmiddleware.NewPermissionMiddleware,
|
||||||
),
|
),
|
||||||
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
|
fx.Invoke(func(log *zap.Logger, db *gorm.DB) {
|
||||||
if err := infrastructure.Migrate(db); err != nil {
|
|
||||||
log.Fatal("rbac migration failed", zap.Error(err))
|
|
||||||
}
|
|
||||||
if err := infrastructure.Seed(db, log); err != nil {
|
if err := infrastructure.Seed(db, log); err != nil {
|
||||||
log.Error("rbac seed failed", zap.Error(err))
|
log.Error("rbac seed failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,22 +2,22 @@ package interfaces
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mengstack/internal/app/middleware"
|
"mengstack/internal/app/middleware"
|
||||||
|
"mengstack/internal/kernel/tenant"
|
||||||
"mengstack/internal/modules/settings/application"
|
"mengstack/internal/modules/settings/application"
|
||||||
"mengstack/internal/modules/settings/infrastructure"
|
"mengstack/internal/modules/settings/infrastructure"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/zap"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
r *gin.Engine,
|
r *gin.Engine,
|
||||||
h *Handler,
|
h *Handler,
|
||||||
authMW gin.HandlerFunc,
|
authMW gin.HandlerFunc,
|
||||||
|
tenantResolver tenant.Resolver,
|
||||||
) {
|
) {
|
||||||
settings := r.Group("/api/v1/settings")
|
settings := r.Group("/api/v1/settings")
|
||||||
settings.Use(authMW, middleware.MultiTenant())
|
settings.Use(authMW, middleware.MultiTenant(tenantResolver))
|
||||||
{
|
{
|
||||||
settings.GET("", h.ListGlobalSettings)
|
settings.GET("", h.ListGlobalSettings)
|
||||||
settings.GET("/global/:key", h.GetGlobalSetting)
|
settings.GET("/global/:key", h.GetGlobalSetting)
|
||||||
@ -36,9 +36,4 @@ var Module = fx.Module("settings",
|
|||||||
application.NewService,
|
application.NewService,
|
||||||
NewHandler,
|
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
97
internal/testutil/db.go
Normal 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
|
||||||
|
}
|
||||||
73
internal/testutil/fixtures.go
Normal file
73
internal/testutil/fixtures.go
Normal 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
150
internal/testutil/server.go
Normal 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
|
||||||
|
}
|
||||||
4
migrations/000002_add_rbac.down.sql
Normal file
4
migrations/000002_add_rbac.down.sql
Normal 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;
|
||||||
36
migrations/000002_add_rbac.up.sql
Normal file
36
migrations/000002_add_rbac.up.sql
Normal 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)
|
||||||
|
);
|
||||||
1
migrations/000003_add_audit_logs.down.sql
Normal file
1
migrations/000003_add_audit_logs.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS audit_logs;
|
||||||
17
migrations/000003_add_audit_logs.up.sql
Normal file
17
migrations/000003_add_audit_logs.up.sql
Normal 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);
|
||||||
1
migrations/000004_add_settings.down.sql
Normal file
1
migrations/000004_add_settings.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS settings;
|
||||||
13
migrations/000004_add_settings.up.sql
Normal file
13
migrations/000004_add_settings.up.sql
Normal 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);
|
||||||
1
migrations/000005_add_notifications.down.sql
Normal file
1
migrations/000005_add_notifications.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS notifications;
|
||||||
14
migrations/000005_add_notifications.up.sql
Normal file
14
migrations/000005_add_notifications.up.sql
Normal 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);
|
||||||
1
migrations/000006_add_examples.down.sql
Normal file
1
migrations/000006_add_examples.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS examples;
|
||||||
16
migrations/000006_add_examples.up.sql
Normal file
16
migrations/000006_add_examples.up.sql
Normal 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
6
migrations/embed.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var Files embed.FS
|
||||||
86
pkg/query/query.go
Normal file
86
pkg/query/query.go
Normal 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"`
|
||||||
|
}
|
||||||
106
tests/benchmarks/auth_bench_test.go
Normal file
106
tests/benchmarks/auth_bench_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
97
tests/benchmarks/middleware_bench_test.go
Normal file
97
tests/benchmarks/middleware_bench_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
tests/integration/auth_test.go
Normal file
87
tests/integration/auth_test.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user