diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80bda7a --- /dev/null +++ b/AGENTS.md @@ -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//` 下创建 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 文档站 | diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86edf25 --- /dev/null +++ b/LICENSE @@ -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 + . + + 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. diff --git a/README.md b/README.md index f357fc3..dc978cd 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,20 @@ -# MengStack API +# MengStack Core -MengStack 是一个基于 Go 的企业级后端框架,采用清晰的分层架构和依赖注入,帮助开发者快速构建高质量的多租户 Web 应用。 +[![Go](https://img.shields.io/badge/Go-1.23+-00ADD8?logo=go)](https://go.dev) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16+-336791?logo=postgresql)](https://www.postgresql.org) +[![Redis](https://img.shields.io/badge/Redis-7+-DC382D?logo=redis)](https://redis.io) +[![License](https://img.shields.io/badge/License-Apache--2.0-blue)](LICENSE) -## 技术栈 +MengStack(软盟开发框架)是一个面向多租户 SaaS 应用的 Go 通用底座。把账号、权限、租户、组织、审计、配置、消息、插件等所有 Web 应用共享的能力一次性做好,让你只专注于业务逻辑。 -| 组件 | 技术选型 | 说明 | -|------|---------|------| -| Web 框架 | [Gin](https://github.com/gin-gonic/gin) | 高性能 HTTP 路由 | -| ORM | [GORM](https://gorm.io) | PostgreSQL 数据访问 | -| 缓存 | [go-redis](https://github.com/redis/go-redis) | Redis 客户端 | -| 依赖注入 | [uber/fx](https://github.com/uber-go/fx) | 生命周期管理 | -| 配置管理 | [Viper](https://github.com/spf13/viper) | 分层配置 + 环境变量 | -| 日志 | [Zap](https://github.com/uber-go/zap) | 结构化高性能日志 | -| 认证 | JWT | Access Token + Refresh Token | +## 为什么选择 MengStack -## 架构设计 - -### 三层隔离 - -``` -cmd/server/ → 启动层:组装依赖,启动服务 -internal/app/ → 基础设施层:数据库、缓存、中间件、健康检查 -internal/modules/ → 业务模块层:按领域划分的独立模块 -``` - -### 模块四层结构 - -每个业务模块遵循统一的四层架构: - -``` -modules/auth/ -├── domain/ → 领域层:实体、DTO、仓储接口 -├── application/ → 应用层:业务逻辑、服务实现 -├── infrastructure/ → 基础设施层:仓储实现、数据库迁移 -└── interfaces/ → 接口层:HTTP Handler、路由注册 -``` - -### 依赖注入 - -使用 `uber/fx` 管理依赖,每个模块通过 `fx.Provide` 注册,框架自动解析依赖图。生命周期钩子(`fx.Lifecycle`)管理服务器启停,禁止在 Provider 中启动 goroutine。 +- **多租户内建** — 共享表 + `tenant_id` 显式过滤,可选 Schema 隔离与 PG 行级安全(RLS)兜底 +- **插件架构** — 通过扩展点和 `.mengplugin` 包格式扩展功能,不改动内核代码 +- **四层分层** — domain → application → infrastructure → interfaces,职责清晰、可测试 +- **编译期依赖注入** — 基于 Uber fx,依赖错误在编译时暴露,不是运行时 +- **安全默认** — JWT 双 Token、RBAC 权限、bcrypt 密码、参数化查询、XSS 过滤、三级限流 +- **可观测性** — Zap 结构化日志(含 trace_id / tenant_id)、健康检查、就绪探针 ## 快速开始 @@ -48,161 +24,232 @@ modules/auth/ - PostgreSQL 16+ - Redis 7+ -### 方式一:Docker Compose(推荐) +### 1. 克隆项目 ```bash -# 克隆项目 -git clone https://mengstackgit.softunis.com/softunis/mengstack-api.git -cd mengstack-api - -# 复制环境变量并修改 -cp .env.example .env - -# 一键启动(PostgreSQL + Redis + API) -docker-compose up -d - -# 查看日志 -docker-compose logs -f api +git clone https://github.com/mengstack/core.git +cd core ``` -### 方式二:本地开发 +### 2. 配置环境 ```bash -# 1. 启动 PostgreSQL 和 Redis(可用 Docker) -docker-compose up -d postgres redis - -# 2. 复制并编辑配置 cp .env.example .env -# 编辑 .env 填入数据库密码等 +# 编辑 .env,填入数据库密码和 JWT 密钥 +``` -# 3. 安装依赖 +### 3. 启动依赖服务 + +```bash +docker-compose up -d postgres redis +``` + +### 4. 启动服务 + +```bash +# 安装依赖 go mod download -# 4. 启动服务 +# 运行迁移 + 启动 make run -# 或 -go run ./cmd/server ``` -服务启动后访问 `http://localhost:2222/health` 验证。 +服务启动后: + +| 端点 | 地址 | +|------|------| +| API | `http://localhost:2222` | +| 健康检查 | `http://localhost:2222/health` | +| 就绪探针 | `http://localhost:2222/readyz` | +| Swagger 文档 | `http://localhost:2222/swagger/index.html` | + +### 或者用 Docker 一键启动 + +```bash +cp .env.example .env +docker-compose up -d +docker-compose logs -f api +``` ## 项目结构 ``` -mengstack-api/ -├── cmd/server/ # 应用入口 -├── configs/ # 配置模板 -│ ├── config.yaml # 基础配置 -│ ├── config.dev.yaml # 开发环境覆盖 -│ └── config.prod.yaml # 生产环境覆盖 +. +├── cmd/ +│ ├── server/ # 主服务入口 +│ ├── migrate/ # 数据库迁移工具 +│ └── smrm/ # CLI 脚手架工具 ├── internal/ -│ ├── app/ # 基础设施 -│ │ ├── cache/ # Redis 模块 -│ │ ├── database/ # PostgreSQL 模块 -│ │ ├── health/ # 健康检查 -│ │ └── middleware/ # 请求ID、多租户中间件 -│ ├── config/ # 配置加载器 -│ ├── kernel/ # 核心工具(错误、响应、租户上下文) -│ ├── logger/ # Zap 日志 +│ ├── app/ # 应用基础设施 +│ │ ├── cache/ # Redis 连接 +│ │ ├── database/ # PostgreSQL 连接 +│ │ ├── health/ # 健康检查 & 就绪探针 +│ │ ├── middleware/ # 请求 ID、认证、多租户、i18n +│ │ ├── migrate/ # 迁移执行器 +│ │ └── upgrade/ # 在线升级预留 +│ ├── config/ # Viper 配置加载 +│ ├── kernel/ # ★ 内核(开源部分) +│ │ ├── errors/ # 统一错误码 +│ │ ├── response/ # 统一响应格式 +│ │ ├── tenant/ # 多租户抽象 +│ │ ├── model/ # 公共基础实体 +│ │ ├── plugin/ # 插件接口 & 沙箱 +│ │ ├── eventbus/ # 事件总线 +│ │ ├── storage/ # 存储抽象 +│ │ ├── cache/ # 缓存工具 +│ │ ├── jobs/ # 后台任务调度 +│ │ ├── i18n/ # 国际化框架 +│ │ └── websocket/ # WebSocket 推送 +│ ├── logger/ # Zap 结构化日志 │ └── modules/ # 业务模块 -│ └── auth/ # 认证模块(注册/登录/刷新Token) -├── migrations/ # 数据库迁移脚本 -├── docs/ # Swagger 文档 -├── assets/ # 静态资源(Logo 等) -├── .env.example # 环境变量模板 -├── docker-compose.yml # 容器编排 -├── Makefile # 常用命令 -└── go.mod # Go 模块定义 +│ ├── auth/ # 认证(注册/登录/刷新) +│ ├── rbac/ # 角色权限 +│ ├── org/ # 组织架构 +│ ├── audit/ # 审计日志 +│ ├── settings/ # 配置管理 +│ ├── notification/# 消息通知 +│ └── example/ # 示例插件 +├── migrations/ # 核心数据库迁移脚本 +├── configs/ # 分层配置模板 +├── docs/ # Swagger 生成文档 +├── test/ # 测试工具 +├── AGENTS.md # AI 辅助开发规范 +└── Makefile # 常用命令 ``` -## 配置系统 +## 核心能力 -MengStack 采用三层配置覆盖机制,优先级从低到高: +### 多租户 -1. **基础配置** `configs/config.yaml` — 所有环境的默认值 -2. **环境覆盖** `configs/config.{mode}.yaml` — 按 `server.mode` 加载(dev/prod) -3. **环境变量** `MENGSTACK_` 前缀 — 最高优先级,适合存放密码等敏感信息 +```go +// 请求头传入租户 ID +// X-Tenant-ID: tenant-abc-123 + +// 框架自动解析并注入上下文 +tenantID := tenant.FromContext(c.Request.Context()) +``` + +支持三种隔离策略,通过配置切换: +- **共享表 + tenant_id**(默认,适合大多数场景) +- **Schema 隔离**(高安全需求) +- **独立数据库**(企业级隔离) + +### 插件系统 + +每个插件是一个独立的模块,声明所需权限,运行时由沙箱强制执行: + +```go +func (p *MyPlugin) Permissions() plugin.Permissions { + return plugin.Permissions{ + Database: []string{"my_table"}, // 只能访问声明的表 + Events: []string{"order.created"}, // 只能发射声明的事件 + Routes: []string{"/api/v1/orders/*"}, + } +} +``` + +插件 panic 不会影响主进程 — 沙箱通过 `recover` 隔离故障。 + +创建新插件: ```bash -# 环境变量命名规则:MENGSTACK_{Section}_{Key} -# 例如: -MENGSTACK_SERVER_PORT=8080 -MENGSTACK_DATABASE_PASSWORD=your-secret -MENGSTACK_JWT_SECRET=your-jwt-secret +go run ./cmd/smrm new module order ``` -**安全提示**:永远不要将真实密码提交到 git。使用 `.env` 文件(已加入 `.gitignore`)或系统环境变量。 +### 统一响应 + +```json +{ + "code": 0, + "message": "success", + "data": { ... }, + "trace_id": "abc-123" +} +``` + +错误码体系:业务错误返回具体码 + 国际化消息,5xx 统一返回"服务器内部错误",原文只进日志。 + +### 配置分层 + +优先级从低到高: + +1. `configs/config.yaml` — 基础默认值 +2. `configs/config.{mode}.yaml` — 环境覆盖(dev / prod) +3. `MENGSTACK_` 环境变量 — 最高优先级 + +```bash +MENGSTACK_DATABASE_PASSWORD=secret +MENGSTACK_JWT_SECRET=your-jwt-key +``` + +## 开发规范 + +### 四层分层 + +| 层 | 文件 | 职责 | 禁止 | +|----|------|------|------| +| Domain | `model.go` | 实体、领域接口 | import gorm/http/redis | +| Application | `service.go` | 用例编排、事务 | 直接操作数据库 | +| Infrastructure | `repo.go` | 数据库/缓存实现 | 含业务逻辑 | +| Interfaces | `handler.go` | 参数校验、调 service | 写业务逻辑 | + +### 十条铁律 + +1. 内核绝不装业务语义(无 article / order / goods) +2. 依赖只能由外向内(domain 层零外部依赖) +3. 跨模块只走接口(禁止直接 import 其他模块的 repo) +4. 依赖方向单调:modules → kernel,禁止反向 +5. tenantID 显式传递(禁止 context 隐式透传) +6. 统一响应格式与错误码 +7. 所有数据库变更走迁移脚本 +8. 禁止 SELECT *、禁止字符串拼接 SQL +9. 品牌写法统一:MengStack + 软盟开发框架 +10. 插件只能通过扩展点接入,内核不依赖任何插件 + +### 规范自检 + +```bash +# 检查内核纯净性(禁止业务语义泄漏) +go run ./cmd/smrm check +``` ## 常用命令 ```bash -make build # 编译二进制 +make build # 编译 make run # 启动开发服务 make test # 运行测试 -make test-cover # 测试覆盖率报告 +make test-cover # 测试覆盖率 make lint # 代码检查 make swagger # 重新生成 Swagger 文档 +make migrate # 运行数据库迁移 make docker-up # Docker 启动全部服务 make docker-down # Docker 停止所有服务 -make clean # 清理构建产物 ``` -## API 概览 - -### 认证接口 - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/v1/auth/register` | 用户注册 | -| POST | `/api/v1/auth/login` | 用户登录 | -| POST | `/api/v1/auth/refresh` | 刷新 Token | -| GET | `/api/v1/auth/me` | 获取当前用户(需认证) | -| GET | `/health` | 健康检查 | - -### 多租户 - -所有业务接口通过 `X-Tenant-ID` 请求头标识租户。框架自动解析租户上下文并注入到数据库查询中。 - -完整 API 文档:启动服务后访问 `/swagger/index.html`。 - -## 添加新模块 - -以创建 `article` 模块为例: - -``` -internal/modules/article/ -├── domain/ -│ ├── entity.go # Article 实体 -│ ├── dto.go # 请求/响应 DTO -│ └── repository.go # 仓储接口 -├── application/ -│ └── service.go # 业务逻辑实现 -├── infrastructure/ -│ ├── repository.go # GORM 仓储实现 -│ └── migrate.go # AutoMigrate 注册 -└── interfaces/ - ├── handler.go # HTTP Handler - └── routes.go # 路由注册(实现 RouteGroup 接口) -``` - -1. 创建上述目录和文件 -2. 在 `internal/app/app.go` 中添加 `fx.Provide` 注册新模块 -3. 在 `infrastructure/migrate.go` 中注册 AutoMigrate -4. 框架自动发现并注册路由 - -## 开发规范 - -- **依赖注入**:所有依赖通过 `fx.Provide` 注册,使用 `fx.In` 结构体注入 -- **生命周期**:服务器启停使用 `fx.Lifecycle` 的 `OnStart/OnStop` 钩子 -- **错误处理**:使用 `kernel/errors` 统一错误码 -- **响应格式**:使用 `kernel/response` 统一 JSON 响应结构 -- **日志**:通过 `logger.L()` 获取全局 Zap 实例 - ## 演示站 -- API 地址:https://mengstackdemo.softunis.com -- Swagger 文档:https://mengstackdemo.softunis.com/swagger/index.html +- API:https://mengstackdemo.softunis.com +- Swagger:https://mengstackdemo.softunis.com/swagger/index.html + +## 技术栈 + +| 组件 | 选型 | 说明 | +|------|------|------| +| 语言 | Go 1.23+ | 编译为单文件,部署极简 | +| Web 框架 | Gin | 高性能 HTTP 路由 | +| ORM | GORM v2 | PostgreSQL 数据访问 | +| 数据库 | PostgreSQL 16 | 多租户原生 + 许可可闭源 | +| 缓存 | Redis 7 | 会话 / 缓存 / 队列 | +| 依赖注入 | Uber fx | 编译期装配,生命周期管理 | +| 配置 | Viper | 分层配置 + 环境变量 | +| 日志 | Zap | 结构化高性能日志 | +| 认证 | JWT | Access + Refresh 双 Token | +| 迁移 | golang-migrate | 版本化数据库变更 | +| API 文档 | Swag | 注解自动生成 Swagger | ## 许可证 -MIT License +[Apache License 2.0](LICENSE) + +Copyright 2026 SoftUnis(软盟) diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..b05e6cd --- /dev/null +++ b/cmd/migrate/main.go @@ -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 [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) + } +} diff --git a/cmd/smrm/main.go b/cmd/smrm/main.go new file mode 100644 index 0000000..7c2c8c1 --- /dev/null +++ b/cmd/smrm/main.go @@ -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 ") + 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 创建标准四层模块骨架") + 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, + ), +) +` diff --git a/configs/config.yaml b/configs/config.yaml index 96c4461..cada4de 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -24,3 +24,7 @@ jwt: log: level: info format: json + +tenant: + mode: multi + default_id: "00000000-0000-0000-0000-000000000001" diff --git a/docs/docs.go b/docs/docs.go index 0a8b565..329a40f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,26 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/upgrade/check": { + "get": { + "description": "检查是否有新版本可用(当前为预留接口,返回当前版本信息)", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "升级检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_app_upgrade.UpgradeStatus" + } + } + } + } + }, "/api/v1/audit/logs": { "get": { "security": [ @@ -69,13 +89,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -109,7 +129,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateNotificationRequest" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.CreateNotificationRequest" } } ], @@ -119,13 +139,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.NotificationDTO" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO" } } } @@ -135,7 +155,7 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -163,13 +183,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -197,13 +217,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -252,19 +272,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -303,13 +323,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.NotificationDTO" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO" } } } @@ -319,13 +339,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -357,19 +377,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -403,19 +423,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -445,7 +465,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -453,7 +473,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -464,7 +484,7 @@ const docTemplate = `{ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -496,7 +516,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateTenantRequest" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.CreateTenantRequest" } } ], @@ -506,13 +526,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -522,13 +542,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -567,13 +587,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -583,13 +603,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -628,7 +648,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateTenantRequest" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.UpdateTenantRequest" } } ], @@ -638,13 +658,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -654,13 +674,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -692,19 +712,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -742,7 +762,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -750,7 +770,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } } } @@ -761,7 +781,7 @@ const docTemplate = `{ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -791,7 +811,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -799,7 +819,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -810,7 +830,7 @@ const docTemplate = `{ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -842,7 +862,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.CreateRoleRequest" } } ], @@ -852,13 +872,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -868,13 +888,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -913,13 +933,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -929,13 +949,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -974,7 +994,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.UpdateRoleRequest" } } ], @@ -984,13 +1004,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -1000,13 +1020,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1038,19 +1058,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1089,7 +1109,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1097,7 +1117,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } } } @@ -1108,13 +1128,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1153,7 +1173,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.SetRolePermissionsRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest" } } ], @@ -1161,19 +1181,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1212,7 +1232,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1220,7 +1240,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -1231,13 +1251,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1276,7 +1296,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.AssignRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.AssignRoleRequest" } } ], @@ -1284,19 +1304,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1337,19 +1357,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1379,7 +1399,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1387,7 +1407,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1398,7 +1418,7 @@ const docTemplate = `{ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1437,13 +1457,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1453,13 +1473,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1498,7 +1518,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateSettingRequest" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest" } } ], @@ -1506,13 +1526,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1542,7 +1562,7 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1550,7 +1570,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1561,7 +1581,7 @@ const docTemplate = `{ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1600,13 +1620,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1616,13 +1636,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1661,7 +1681,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateSettingRequest" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest" } } ], @@ -1669,13 +1689,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1707,19 +1727,39 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" + } + } + } + } + }, + "/api/version": { + "get": { + "description": "返回构建版本、提交哈希、编译时间等", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "版本信息", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mengstack_internal_app_version.Info" } } } @@ -1745,7 +1785,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.LoginRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.LoginRequest" } } ], @@ -1755,13 +1795,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1771,13 +1811,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1813,13 +1853,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1829,13 +1869,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1861,7 +1901,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.RegisterRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest" } } ], @@ -1871,13 +1911,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1887,13 +1927,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1939,6 +1979,31 @@ const docTemplate = `{ } } }, + "/healthz": { + "get": { + "description": "Kubernetes liveness probe,进程存活即返回 200", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "存活探针", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + } + }, "/password": { "post": { "security": [ @@ -1967,7 +2032,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.ChangePasswordRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest" } } ], @@ -1975,19 +2040,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -2015,7 +2080,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -2045,13 +2110,13 @@ const docTemplate = `{ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.UserDTO" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.UserDTO" } } } @@ -2061,13 +2126,61 @@ const docTemplate = `{ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" + } + } + } + } + }, + "/readyz": { + "get": { + "description": "Kubernetes readiness probe,检查数据库和 Redis 连接", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "就绪探针", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "redis": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "redis": { + "type": "string" + }, + "status": { + "type": "string" + } + } } } } @@ -2075,18 +2188,97 @@ const docTemplate = `{ } }, "definitions": { - "domain.AssignRoleRequest": { + "internal_app_upgrade.Manifest": { "type": "object", - "required": [ - "role_id" - ], "properties": { - "role_id": { + "changelog": { + "type": "string" + }, + "checksum_sha256": { + "type": "string" + }, + "critical": { + "type": "boolean" + }, + "download_url": { + "type": "string" + }, + "min_version": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "size": { "type": "integer" + }, + "version": { + "type": "string" } } }, - "domain.ChangePasswordRequest": { + "internal_app_upgrade.UpgradeStatus": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "checked_at": { + "type": "string" + }, + "critical": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "manifest": { + "$ref": "#/definitions/internal_app_upgrade.Manifest" + } + } + }, + "mengstack_internal_app_version.Info": { + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "build_date": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "go_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "mengstack_internal_kernel_response.Response": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": {}, + "message": { + "type": "string" + }, + "trace_id": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.ChangePasswordRequest": { "type": "object", "required": [ "new_password", @@ -2103,7 +2295,88 @@ const docTemplate = `{ } } }, - "domain.CreateNotificationRequest": { + "mengstack_internal_modules_auth_domain.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "mengstack_internal_modules_auth_domain.TokenPair": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.UserDTO": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "tenant_id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "mengstack_internal_modules_notification_domain.CreateNotificationRequest": { "type": "object", "required": [ "title", @@ -2124,51 +2397,7 @@ const docTemplate = `{ } } }, - "domain.CreateRoleRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "domain.CreateTenantRequest": { - "type": "object", - "required": [ - "name", - "slug" - ], - "properties": { - "name": { - "type": "string" - }, - "slug": { - "type": "string" - } - } - }, - "domain.LoginRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, - "domain.NotificationDTO": { + "mengstack_internal_modules_notification_domain.NotificationDTO": { "type": "object", "properties": { "content": { @@ -2197,7 +2426,84 @@ const docTemplate = `{ } } }, - "domain.PermissionDTO": { + "mengstack_internal_modules_org_domain.CreateTenantRequest": { + "type": "object", + "required": [ + "name", + "slug" + ], + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, + "mengstack_internal_modules_org_domain.TenantDTO": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "mengstack_internal_modules_org_domain.UpdateTenantRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "mengstack_internal_modules_rbac_domain.AssignRoleRequest": { + "type": "object", + "required": [ + "role_id" + ], + "properties": { + "role_id": { + "type": "integer" + } + } + }, + "mengstack_internal_modules_rbac_domain.CreateRoleRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "mengstack_internal_modules_rbac_domain.PermissionDTO": { "type": "object", "properties": { "code": { @@ -2217,33 +2523,7 @@ const docTemplate = `{ } } }, - "domain.RegisterRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string", - "maxLength": 128, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "domain.RoleDTO": { + "mengstack_internal_modules_rbac_domain.RoleDTO": { "type": "object", "properties": { "description": { @@ -2261,7 +2541,7 @@ const docTemplate = `{ "permissions": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } }, "tenant_id": { @@ -2269,7 +2549,7 @@ const docTemplate = `{ } } }, - "domain.SetRolePermissionsRequest": { + "mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest": { "type": "object", "required": [ "permission_ids" @@ -2283,7 +2563,18 @@ const docTemplate = `{ } } }, - "domain.SettingDTO": { + "mengstack_internal_modules_rbac_domain.UpdateRoleRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "mengstack_internal_modules_settings_domain.SettingDTO": { "type": "object", "properties": { "id": { @@ -2312,55 +2603,7 @@ const docTemplate = `{ } } }, - "domain.TenantDTO": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } - } - }, - "domain.TokenPair": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "expires_in": { - "type": "integer" - }, - "refresh_token": { - "type": "string" - } - } - }, - "domain.UpdateRoleRequest": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "domain.UpdateSettingRequest": { + "mengstack_internal_modules_settings_domain.UpdateSettingRequest": { "type": "object", "required": [ "value" @@ -2373,61 +2616,6 @@ const docTemplate = `{ "type": "string" } } - }, - "domain.UpdateTenantRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - }, - "domain.UserDTO": { - "type": "object", - "properties": { - "avatar": { - "type": "string" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "tenant_id": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "response.Response": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "data": {}, - "message": { - "type": "string" - }, - "trace_id": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index a5a4a0b..d918b59 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -9,6 +9,26 @@ "host": "localhost:2222", "basePath": "/api/v1", "paths": { + "/api/upgrade/check": { + "get": { + "description": "检查是否有新版本可用(当前为预留接口,返回当前版本信息)", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "升级检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_app_upgrade.UpgradeStatus" + } + } + } + } + }, "/api/v1/audit/logs": { "get": { "security": [ @@ -63,13 +83,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -103,7 +123,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateNotificationRequest" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.CreateNotificationRequest" } } ], @@ -113,13 +133,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.NotificationDTO" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO" } } } @@ -129,7 +149,7 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -157,13 +177,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -191,13 +211,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -246,19 +266,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -297,13 +317,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.NotificationDTO" + "$ref": "#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO" } } } @@ -313,13 +333,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -351,19 +371,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -397,19 +417,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -439,7 +459,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -447,7 +467,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -458,7 +478,7 @@ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -490,7 +510,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateTenantRequest" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.CreateTenantRequest" } } ], @@ -500,13 +520,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -516,13 +536,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -561,13 +581,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -577,13 +597,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -622,7 +642,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateTenantRequest" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.UpdateTenantRequest" } } ], @@ -632,13 +652,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TenantDTO" + "$ref": "#/definitions/mengstack_internal_modules_org_domain.TenantDTO" } } } @@ -648,13 +668,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -686,19 +706,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -736,7 +756,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -744,7 +764,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } } } @@ -755,7 +775,7 @@ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -785,7 +805,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -793,7 +813,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -804,7 +824,7 @@ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -836,7 +856,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.CreateRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.CreateRoleRequest" } } ], @@ -846,13 +866,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -862,13 +882,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -907,13 +927,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -923,13 +943,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -968,7 +988,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.UpdateRoleRequest" } } ], @@ -978,13 +998,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -994,13 +1014,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1032,19 +1052,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1083,7 +1103,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1091,7 +1111,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } } } @@ -1102,13 +1122,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1147,7 +1167,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.SetRolePermissionsRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest" } } ], @@ -1155,19 +1175,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1206,7 +1226,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1214,7 +1234,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.RoleDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO" } } } @@ -1225,13 +1245,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1270,7 +1290,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.AssignRoleRequest" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.AssignRoleRequest" } } ], @@ -1278,19 +1298,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1331,19 +1351,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1373,7 +1393,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1381,7 +1401,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1392,7 +1412,7 @@ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1431,13 +1451,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1447,13 +1467,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1492,7 +1512,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateSettingRequest" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest" } } ], @@ -1500,13 +1520,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1536,7 +1556,7 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", @@ -1544,7 +1564,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1555,7 +1575,7 @@ "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1594,13 +1614,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.SettingDTO" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.SettingDTO" } } } @@ -1610,13 +1630,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1655,7 +1675,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.UpdateSettingRequest" + "$ref": "#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest" } } ], @@ -1663,13 +1683,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1701,19 +1721,39 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" + } + } + } + } + }, + "/api/version": { + "get": { + "description": "返回构建版本、提交哈希、编译时间等", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "版本信息", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mengstack_internal_app_version.Info" } } } @@ -1739,7 +1779,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.LoginRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.LoginRequest" } } ], @@ -1749,13 +1789,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1765,13 +1805,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1807,13 +1847,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1823,13 +1863,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1855,7 +1895,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.RegisterRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest" } } ], @@ -1865,13 +1905,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.TokenPair" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.TokenPair" } } } @@ -1881,13 +1921,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -1933,6 +1973,31 @@ } } }, + "/healthz": { + "get": { + "description": "Kubernetes liveness probe,进程存活即返回 200", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "存活探针", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + } + }, "/password": { "post": { "security": [ @@ -1961,7 +2026,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/domain.ChangePasswordRequest" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest" } } ], @@ -1969,19 +2034,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -2009,7 +2074,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } } } @@ -2039,13 +2104,13 @@ "schema": { "allOf": [ { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" }, { "type": "object", "properties": { "data": { - "$ref": "#/definitions/domain.UserDTO" + "$ref": "#/definitions/mengstack_internal_modules_auth_domain.UserDTO" } } } @@ -2055,13 +2120,61 @@ "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" } }, "404": { "description": "Not Found", "schema": { - "$ref": "#/definitions/response.Response" + "$ref": "#/definitions/mengstack_internal_kernel_response.Response" + } + } + } + } + }, + "/readyz": { + "get": { + "description": "Kubernetes readiness probe,检查数据库和 Redis 连接", + "produces": [ + "application/json" + ], + "tags": [ + "System" + ], + "summary": "就绪探针", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "redis": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "properties": { + "database": { + "type": "string" + }, + "redis": { + "type": "string" + }, + "status": { + "type": "string" + } + } } } } @@ -2069,18 +2182,97 @@ } }, "definitions": { - "domain.AssignRoleRequest": { + "internal_app_upgrade.Manifest": { "type": "object", - "required": [ - "role_id" - ], "properties": { - "role_id": { + "changelog": { + "type": "string" + }, + "checksum_sha256": { + "type": "string" + }, + "critical": { + "type": "boolean" + }, + "download_url": { + "type": "string" + }, + "min_version": { + "type": "string" + }, + "published_at": { + "type": "string" + }, + "size": { "type": "integer" + }, + "version": { + "type": "string" } } }, - "domain.ChangePasswordRequest": { + "internal_app_upgrade.UpgradeStatus": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "checked_at": { + "type": "string" + }, + "critical": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "manifest": { + "$ref": "#/definitions/internal_app_upgrade.Manifest" + } + } + }, + "mengstack_internal_app_version.Info": { + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "build_date": { + "type": "string" + }, + "commit": { + "type": "string" + }, + "go_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "mengstack_internal_kernel_response.Response": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "data": {}, + "message": { + "type": "string" + }, + "trace_id": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.ChangePasswordRequest": { "type": "object", "required": [ "new_password", @@ -2097,7 +2289,88 @@ } } }, - "domain.CreateNotificationRequest": { + "mengstack_internal_modules_auth_domain.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string", + "maxLength": 128, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "mengstack_internal_modules_auth_domain.TokenPair": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "refresh_token": { + "type": "string" + } + } + }, + "mengstack_internal_modules_auth_domain.UserDTO": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "tenant_id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "mengstack_internal_modules_notification_domain.CreateNotificationRequest": { "type": "object", "required": [ "title", @@ -2118,51 +2391,7 @@ } } }, - "domain.CreateRoleRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "domain.CreateTenantRequest": { - "type": "object", - "required": [ - "name", - "slug" - ], - "properties": { - "name": { - "type": "string" - }, - "slug": { - "type": "string" - } - } - }, - "domain.LoginRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, - "domain.NotificationDTO": { + "mengstack_internal_modules_notification_domain.NotificationDTO": { "type": "object", "properties": { "content": { @@ -2191,7 +2420,84 @@ } } }, - "domain.PermissionDTO": { + "mengstack_internal_modules_org_domain.CreateTenantRequest": { + "type": "object", + "required": [ + "name", + "slug" + ], + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, + "mengstack_internal_modules_org_domain.TenantDTO": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "mengstack_internal_modules_org_domain.UpdateTenantRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, + "mengstack_internal_modules_rbac_domain.AssignRoleRequest": { + "type": "object", + "required": [ + "role_id" + ], + "properties": { + "role_id": { + "type": "integer" + } + } + }, + "mengstack_internal_modules_rbac_domain.CreateRoleRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "mengstack_internal_modules_rbac_domain.PermissionDTO": { "type": "object", "properties": { "code": { @@ -2211,33 +2517,7 @@ } } }, - "domain.RegisterRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string", - "maxLength": 128, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "domain.RoleDTO": { + "mengstack_internal_modules_rbac_domain.RoleDTO": { "type": "object", "properties": { "description": { @@ -2255,7 +2535,7 @@ "permissions": { "type": "array", "items": { - "$ref": "#/definitions/domain.PermissionDTO" + "$ref": "#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO" } }, "tenant_id": { @@ -2263,7 +2543,7 @@ } } }, - "domain.SetRolePermissionsRequest": { + "mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest": { "type": "object", "required": [ "permission_ids" @@ -2277,7 +2557,18 @@ } } }, - "domain.SettingDTO": { + "mengstack_internal_modules_rbac_domain.UpdateRoleRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "mengstack_internal_modules_settings_domain.SettingDTO": { "type": "object", "properties": { "id": { @@ -2306,55 +2597,7 @@ } } }, - "domain.TenantDTO": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } - } - }, - "domain.TokenPair": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "expires_in": { - "type": "integer" - }, - "refresh_token": { - "type": "string" - } - } - }, - "domain.UpdateRoleRequest": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "domain.UpdateSettingRequest": { + "mengstack_internal_modules_settings_domain.UpdateSettingRequest": { "type": "object", "required": [ "value" @@ -2367,61 +2610,6 @@ "type": "string" } } - }, - "domain.UpdateTenantRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "status": { - "type": "integer" - } - } - }, - "domain.UserDTO": { - "type": "object", - "properties": { - "avatar": { - "type": "string" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "tenant_id": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "response.Response": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "data": {}, - "message": { - "type": "string" - }, - "trace_id": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b9b16d3..0501ef0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,13 +1,65 @@ basePath: /api/v1 definitions: - domain.AssignRoleRequest: + internal_app_upgrade.Manifest: properties: - role_id: + changelog: + type: string + checksum_sha256: + type: string + critical: + type: boolean + download_url: + type: string + min_version: + type: string + published_at: + type: string + size: type: integer - required: - - role_id + version: + type: string type: object - domain.ChangePasswordRequest: + internal_app_upgrade.UpgradeStatus: + properties: + available: + type: boolean + checked_at: + type: string + critical: + type: boolean + current_version: + type: string + latest_version: + type: string + manifest: + $ref: '#/definitions/internal_app_upgrade.Manifest' + type: object + mengstack_internal_app_version.Info: + properties: + arch: + type: string + build_date: + type: string + commit: + type: string + go_version: + type: string + os: + type: string + version: + type: string + type: object + mengstack_internal_kernel_response.Response: + properties: + code: + type: integer + data: {} + message: + type: string + trace_id: + type: string + type: object + mengstack_internal_modules_auth_domain.ChangePasswordRequest: properties: new_password: maxLength: 128 @@ -19,40 +71,7 @@ definitions: - new_password - old_password type: object - domain.CreateNotificationRequest: - properties: - content: - type: string - title: - type: string - type: - type: string - user_id: - type: integer - required: - - title - - user_id - type: object - domain.CreateRoleRequest: - properties: - description: - type: string - name: - type: string - required: - - name - type: object - domain.CreateTenantRequest: - properties: - name: - type: string - slug: - type: string - required: - - name - - slug - type: object - domain.LoginRequest: + mengstack_internal_modules_auth_domain.LoginRequest: properties: email: type: string @@ -62,39 +81,7 @@ definitions: - email - password type: object - domain.NotificationDTO: - properties: - content: - type: string - created_at: - type: string - id: - type: integer - is_read: - type: boolean - tenant_id: - type: string - title: - type: string - type: - type: string - user_id: - type: integer - type: object - domain.PermissionDTO: - properties: - code: - type: string - description: - type: string - id: - type: integer - module: - type: string - name: - type: string - type: object - domain.RegisterRequest: + mengstack_internal_modules_auth_domain.RegisterRequest: properties: email: type: string @@ -113,7 +100,129 @@ definitions: - password - username type: object - domain.RoleDTO: + mengstack_internal_modules_auth_domain.TokenPair: + properties: + access_token: + type: string + expires_in: + type: integer + refresh_token: + type: string + type: object + mengstack_internal_modules_auth_domain.UserDTO: + properties: + avatar: + type: string + email: + type: string + id: + type: integer + nickname: + type: string + status: + type: integer + tenant_id: + type: string + username: + type: string + type: object + mengstack_internal_modules_notification_domain.CreateNotificationRequest: + properties: + content: + type: string + title: + type: string + type: + type: string + user_id: + type: integer + required: + - title + - user_id + type: object + mengstack_internal_modules_notification_domain.NotificationDTO: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + is_read: + type: boolean + tenant_id: + type: string + title: + type: string + type: + type: string + user_id: + type: integer + type: object + mengstack_internal_modules_org_domain.CreateTenantRequest: + properties: + name: + type: string + slug: + type: string + required: + - name + - slug + type: object + mengstack_internal_modules_org_domain.TenantDTO: + properties: + created_at: + type: string + id: + type: string + name: + type: string + slug: + type: string + status: + type: integer + updated_at: + type: string + type: object + mengstack_internal_modules_org_domain.UpdateTenantRequest: + properties: + name: + type: string + slug: + type: string + status: + type: integer + type: object + mengstack_internal_modules_rbac_domain.AssignRoleRequest: + properties: + role_id: + type: integer + required: + - role_id + type: object + mengstack_internal_modules_rbac_domain.CreateRoleRequest: + properties: + description: + type: string + name: + type: string + required: + - name + type: object + mengstack_internal_modules_rbac_domain.PermissionDTO: + properties: + code: + type: string + description: + type: string + id: + type: integer + module: + type: string + name: + type: string + type: object + mengstack_internal_modules_rbac_domain.RoleDTO: properties: description: type: string @@ -125,12 +234,12 @@ definitions: type: string permissions: items: - $ref: '#/definitions/domain.PermissionDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO' type: array tenant_id: type: string type: object - domain.SetRolePermissionsRequest: + mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest: properties: permission_ids: items: @@ -139,7 +248,14 @@ definitions: required: - permission_ids type: object - domain.SettingDTO: + mengstack_internal_modules_rbac_domain.UpdateRoleRequest: + properties: + description: + type: string + name: + type: string + type: object + mengstack_internal_modules_settings_domain.SettingDTO: properties: id: type: integer @@ -158,38 +274,7 @@ definitions: value: type: string type: object - domain.TenantDTO: - properties: - created_at: - type: string - id: - type: string - name: - type: string - slug: - type: string - status: - type: integer - updated_at: - type: string - type: object - domain.TokenPair: - properties: - access_token: - type: string - expires_in: - type: integer - refresh_token: - type: string - type: object - domain.UpdateRoleRequest: - properties: - description: - type: string - name: - type: string - type: object - domain.UpdateSettingRequest: + mengstack_internal_modules_settings_domain.UpdateSettingRequest: properties: type: type: string @@ -198,42 +283,6 @@ definitions: required: - value type: object - domain.UpdateTenantRequest: - properties: - name: - type: string - slug: - type: string - status: - type: integer - type: object - domain.UserDTO: - properties: - avatar: - type: string - email: - type: string - id: - type: integer - nickname: - type: string - status: - type: integer - tenant_id: - type: string - username: - type: string - type: object - response.Response: - properties: - code: - type: integer - data: {} - message: - type: string - trace_id: - type: string - type: object host: localhost:2222 info: contact: {} @@ -241,6 +290,19 @@ info: title: MengStack API version: 0.1.0 paths: + /api/upgrade/check: + get: + description: 检查是否有新版本可用(当前为预留接口,返回当前版本信息) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_app_upgrade.UpgradeStatus' + summary: 升级检查 + tags: + - System /api/v1/audit/logs: get: description: 分页查询审计日志,支持按用户、操作、资源筛选 @@ -271,11 +333,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -293,7 +355,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.CreateNotificationRequest' + $ref: '#/definitions/mengstack_internal_modules_notification_domain.CreateNotificationRequest' produces: - application/json responses: @@ -301,15 +363,15 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.NotificationDTO' + $ref: '#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -329,15 +391,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -359,19 +421,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.NotificationDTO' + $ref: '#/definitions/mengstack_internal_modules_notification_domain.NotificationDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -391,15 +453,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -415,11 +477,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -435,11 +497,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -469,15 +531,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -494,17 +556,17 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.TenantDTO' + $ref: '#/definitions/mengstack_internal_modules_org_domain.TenantDTO' type: array type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -521,7 +583,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.CreateTenantRequest' + $ref: '#/definitions/mengstack_internal_modules_org_domain.CreateTenantRequest' produces: - application/json responses: @@ -529,19 +591,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TenantDTO' + $ref: '#/definitions/mengstack_internal_modules_org_domain.TenantDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "409": description: Conflict schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -561,15 +623,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -591,19 +653,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TenantDTO' + $ref: '#/definitions/mengstack_internal_modules_org_domain.TenantDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -625,7 +687,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.UpdateTenantRequest' + $ref: '#/definitions/mengstack_internal_modules_org_domain.UpdateTenantRequest' produces: - application/json responses: @@ -633,19 +695,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TenantDTO' + $ref: '#/definitions/mengstack_internal_modules_org_domain.TenantDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -667,17 +729,17 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.PermissionDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO' type: array type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -694,17 +756,17 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.RoleDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO' type: array type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -721,7 +783,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.CreateRoleRequest' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.CreateRoleRequest' produces: - application/json responses: @@ -729,19 +791,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.RoleDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "409": description: Conflict schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -761,15 +823,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "403": description: Forbidden schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -791,19 +853,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.RoleDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -825,7 +887,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.UpdateRoleRequest' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.UpdateRoleRequest' produces: - application/json responses: @@ -833,19 +895,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.RoleDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -868,21 +930,21 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.PermissionDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.PermissionDTO' type: array type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -904,22 +966,22 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.SetRolePermissionsRequest' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.SetRolePermissionsRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -942,21 +1004,21 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.RoleDTO' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.RoleDTO' type: array type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -978,22 +1040,22 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.AssignRoleRequest' + $ref: '#/definitions/mengstack_internal_modules_rbac_domain.AssignRoleRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "409": description: Conflict schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1018,15 +1080,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1043,17 +1105,17 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.SettingDTO' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.SettingDTO' type: array type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1076,19 +1138,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.SettingDTO' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.SettingDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1110,18 +1172,18 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.UpdateSettingRequest' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1138,17 +1200,17 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: items: - $ref: '#/definitions/domain.SettingDTO' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.SettingDTO' type: array type: object "500": description: Internal Server Error schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1168,15 +1230,15 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1198,19 +1260,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.SettingDTO' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.SettingDTO' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1232,24 +1294,37 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.UpdateSettingRequest' + $ref: '#/definitions/mengstack_internal_modules_settings_domain.UpdateSettingRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] summary: 更新租户配置 tags: - Settings + /api/version: + get: + description: 返回构建版本、提交哈希、编译时间等 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mengstack_internal_app_version.Info' + summary: 版本信息 + tags: + - System /auth/login: post: consumes: @@ -1261,7 +1336,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.LoginRequest' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.LoginRequest' produces: - application/json responses: @@ -1269,19 +1344,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TokenPair' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "401": description: Unauthorized schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' summary: 用户登录 tags: - Auth @@ -1304,19 +1379,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TokenPair' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "401": description: Unauthorized schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' summary: 刷新 Token tags: - Auth @@ -1331,7 +1406,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.RegisterRequest' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.RegisterRequest' produces: - application/json responses: @@ -1339,19 +1414,19 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.TokenPair' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.TokenPair' type: object "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "409": description: Conflict schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' summary: 用户注册 tags: - Auth @@ -1381,6 +1456,22 @@ paths: summary: 健康检查 tags: - System + /healthz: + get: + description: Kubernetes liveness probe,进程存活即返回 200 + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + status: + type: string + type: object + summary: 存活探针 + tags: + - System /password: post: consumes: @@ -1392,22 +1483,22 @@ paths: name: request required: true schema: - $ref: '#/definitions/domain.ChangePasswordRequest' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.ChangePasswordRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "400": description: Bad Request schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "401": description: Unauthorized schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1423,7 +1514,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] @@ -1440,25 +1531,56 @@ paths: description: OK schema: allOf: - - $ref: '#/definitions/response.Response' + - $ref: '#/definitions/mengstack_internal_kernel_response.Response' - properties: data: - $ref: '#/definitions/domain.UserDTO' + $ref: '#/definitions/mengstack_internal_modules_auth_domain.UserDTO' type: object "401": description: Unauthorized schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' "404": description: Not Found schema: - $ref: '#/definitions/response.Response' + $ref: '#/definitions/mengstack_internal_kernel_response.Response' security: - Bearer: [] - TenantID: [] summary: 获取当前用户信息 tags: - Auth + /readyz: + get: + description: Kubernetes readiness probe,检查数据库和 Redis 连接 + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + database: + type: string + redis: + type: string + status: + type: string + type: object + "503": + description: Service Unavailable + schema: + properties: + database: + type: string + redis: + type: string + status: + type: string + type: object + summary: 就绪探针 + tags: + - System securityDefinitions: Bearer: description: Bearer diff --git a/go.mod b/go.mod index 048d12a..fd6be5e 100644 --- a/go.mod +++ b/go.mod @@ -1,33 +1,39 @@ module mengstack -go 1.23.0 +go 1.25.11 require ( github.com/gin-gonic/gin v1.10.0 + github.com/go-co-op/gocron/v2 v2.22.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/golang-migrate/migrate/v4 v4.20.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/redis/go-redis/v9 v9.7.0 github.com/spf13/viper v1.19.0 go.uber.org/fx v1.23.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.53.0 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.12 ) require ( - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -38,8 +44,8 @@ require ( go.uber.org/dig v1.18.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/sync v0.12.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) @@ -76,11 +82,11 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect - google.golang.org/protobuf v1.34.1 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ddb04d4..8809191 100644 --- a/go.sum +++ b/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/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= @@ -12,12 +16,16 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,6 +33,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -37,6 +57,12 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I= +github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -59,25 +85,31 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU= +github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -95,6 +127,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -105,19 +139,37 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= @@ -145,8 +197,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= @@ -160,6 +213,16 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.23.0 h1:lIr/gYWQGfTwGcSXWXu4vP5Ws6iqnNEIY+F/aFzCKTg= @@ -175,24 +238,24 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -201,8 +264,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -211,16 +274,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/app.go b/internal/app/app.go index ae93188..da9e5b6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -10,15 +10,23 @@ import ( "mengstack/internal/app/database" "mengstack/internal/app/health" "mengstack/internal/app/middleware" + appmigrate "mengstack/internal/app/migrate" + "mengstack/internal/app/upgrade" "mengstack/internal/config" + "mengstack/internal/kernel/i18n" "mengstack/internal/kernel/plugin" + "mengstack/internal/kernel/jobs" + "mengstack/internal/kernel/tenant" + kws "mengstack/internal/kernel/websocket" "mengstack/internal/logger" + "mengstack/migrations" authinterfaces "mengstack/internal/modules/auth/interfaces" rbacinterfaces "mengstack/internal/modules/rbac/interfaces" orginterfaces "mengstack/internal/modules/org/interfaces" auditinterfaces "mengstack/internal/modules/audit/interfaces" settingsinterfaces "mengstack/internal/modules/settings/interfaces" notificationinterfaces "mengstack/internal/modules/notification/interfaces" + dashboardinterfaces "mengstack/internal/modules/dashboard/interfaces" exampleinterfaces "mengstack/internal/modules/example/interfaces" "github.com/gin-gonic/gin" @@ -32,8 +40,19 @@ import ( ) var Module = fx.Module("app", - fx.Provide(newEngine), - fx.Provide(newServer), + fx.Provide( + newEngine, + newServer, + func(log *zap.Logger) *kws.Hub { + return kws.NewHub(log) + }, + func(hub *kws.Hub, log *zap.Logger) *kws.Handler { + return kws.NewHandler(hub, log) + }, + func(log *zap.Logger) (*jobs.Scheduler, error) { + return jobs.NewScheduler(log) + }, + ), fx.Invoke(registerLifecycle), ) @@ -49,7 +68,9 @@ func newEngine( auditHandler *auditinterfaces.Handler, settingsHandler *settingsinterfaces.Handler, notificationHandler *notificationinterfaces.Handler, + dashboardHandler *dashboardinterfaces.Handler, examplePlugin *exampleinterfaces.ExamplePlugin, + wsHandler *kws.Handler, ) *gin.Engine { ginMode := "release" if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { @@ -62,23 +83,35 @@ func newEngine( r.Use(middleware.RequestLogger(log)) r.Use(middleware.SecurityHeaders()) r.Use(middleware.CORS()) - r.Use(middleware.RateLimit(100, time.Minute)) + r.Use(middleware.AcceptLanguage()) + r.Use(middleware.RateLimitRedis(rdb)) r.Use(middleware.BodyLimit(10 << 20)) r.Use(middleware.Recovery(log)) healthHandler := health.NewHandler(db, rdb, log) r.GET("/health", healthHandler.Handle) + r.GET("/healthz", healthHandler.Healthz) + r.GET("/readyz", healthHandler.Readyz) + r.GET("/api/version", healthHandler.Version) + + upgradeHandler := upgrade.NewHandler() + r.GET("/api/upgrade/check", upgradeHandler.Check) r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - authinterfaces.SetupRoutes(r, authHandler, authMW) - rbacinterfaces.SetupRoutes(r, rbacHandler, authMW) - orginterfaces.SetupRoutes(r, orgHandler, authMW) - auditinterfaces.SetupRoutes(r, auditHandler, authMW) - settingsinterfaces.SetupRoutes(r, settingsHandler, authMW) - notificationinterfaces.SetupRoutes(r, notificationHandler, authMW) + tenantResolver := tenant.NewResolver(cfg.Tenant) - examplePlugin.SetupRoutes(r, authMW) + authinterfaces.SetupRoutes(r, authHandler, authMW, tenantResolver) + rbacinterfaces.SetupRoutes(r, rbacHandler, authMW, tenantResolver) + orginterfaces.SetupRoutes(r, orgHandler, authMW, tenantResolver) + auditinterfaces.SetupRoutes(r, auditHandler, authMW, tenantResolver) + settingsinterfaces.SetupRoutes(r, settingsHandler, authMW, tenantResolver) + notificationinterfaces.SetupRoutes(r, notificationHandler, authMW, tenantResolver) + dashboardinterfaces.SetupRoutes(r, dashboardHandler, authMW, tenantResolver) + + r.GET("/api/v1/ws", authMW, wsHandler.ServeWS) + + examplePlugin.SetupRoutes(r, authMW, tenantResolver) return r } @@ -93,12 +126,38 @@ func newServer(cfg *config.Config, engine *gin.Engine) *http.Server { } } -func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) { +func registerLifecycle(lc fx.Lifecycle, srv *http.Server, db *gorm.DB, sched *jobs.Scheduler, log *zap.Logger) { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { - if err := plugin.InitAll(log); err != nil { + if err := i18n.Init(i18n.Files, "zh"); err != nil { + return fmt.Errorf("init i18n: %w", err) + } + log.Info("i18n initialized") + + if err := appmigrate.Run(db, migrations.Files); err != nil { + return fmt.Errorf("run migrations: %w", err) + } + log.Info("database migrations applied") + + for _, p := range plugin.All() { + meta := p.Metadata() + pfs := p.MigrationsFS() + if pfs == nil { + continue + } + if err := appmigrate.RunPlugin(db, meta.Name, pfs); err != nil { + return fmt.Errorf("plugin %s migrations: %w", meta.Name, err) + } + log.Info("plugin migrations applied", zap.String("plugin", meta.Name)) + } + + sandbox := plugin.NewSandbox(log) + if err := plugin.InitAll(log, sandbox); err != nil { return err } + + sched.Start() + log.Info("server starting", zap.String("addr", srv.Addr)) go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -111,6 +170,9 @@ func registerLifecycle(lc fx.Lifecycle, srv *http.Server, log *zap.Logger) { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() log.Info("server shutting down") + if err := sched.Stop(); err != nil { + log.Error("scheduler stop error", zap.Error(err)) + } return srv.Shutdown(shutdownCtx) }, }) @@ -130,6 +192,7 @@ func NewApp() *fx.App { auditinterfaces.Module, settingsinterfaces.Module, notificationinterfaces.Module, + dashboardinterfaces.Module, exampleinterfaces.Module, Module, ) diff --git a/internal/app/cache/module.go b/internal/app/cache/module.go index 34403b1..2656251 100644 --- a/internal/app/cache/module.go +++ b/internal/app/cache/module.go @@ -2,13 +2,19 @@ package cache import ( "mengstack/internal/config" + "mengstack/internal/kernel/cache" "go.uber.org/fx" "github.com/redis/go-redis/v9" ) var Module = fx.Module("cache", - fx.Provide(func(cfg *config.Config) (*redis.Client, error) { - return NewRedis(cfg.Redis) - }), + fx.Provide( + func(cfg *config.Config) (*redis.Client, error) { + return NewRedis(cfg.Redis) + }, + func(rdb *redis.Client) *cache.Store { + return cache.NewStore(rdb, "mengstack") + }, + ), ) diff --git a/internal/app/health/handler.go b/internal/app/health/handler.go index d40f834..dcaf730 100644 --- a/internal/app/health/handler.go +++ b/internal/app/health/handler.go @@ -5,6 +5,8 @@ import ( "net/http" "time" + "mengstack/internal/app/version" + "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "go.uber.org/zap" @@ -51,7 +53,71 @@ func (h *Handler) Handle(c *gin.Context) { "database": dbStatus, "redis": redisStatus, "timestamp": time.Now().Unix(), - "version": "0.1.0", + "version": version.Version, "trace_id": c.GetString("trace_id"), }) } + +// Version godoc +// @Summary 版本信息 +// @Description 返回构建版本、提交哈希、编译时间等 +// @Tags System +// @Produce json +// @Success 200 {object} version.Info +// @Router /api/version [get] +func (h *Handler) Version(c *gin.Context) { + c.JSON(http.StatusOK, version.Current()) +} + +// Healthz godoc +// @Summary 存活探针 +// @Description Kubernetes liveness probe,进程存活即返回 200 +// @Tags System +// @Produce json +// @Success 200 {object} object{status=string} +// @Router /healthz [get] +func (h *Handler) Healthz(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// Readyz godoc +// @Summary 就绪探针 +// @Description Kubernetes readiness probe,检查数据库和 Redis 连接 +// @Tags System +// @Produce json +// @Success 200 {object} object{status=string,database=string,redis=string} +// @Failure 503 {object} object{status=string,database=string,redis=string} +// @Router /readyz [get] +func (h *Handler) Readyz(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + dbOK := true + sqlDB, err := h.db.DB() + if err != nil || sqlDB.Ping() != nil { + dbOK = false + } + + redisOK := true + if h.rdb.Ping(ctx).Err() != nil { + redisOK = false + } + + body := gin.H{ + "database": boolToStatus(dbOK), + "redis": boolToStatus(redisOK), + } + + if !dbOK || !redisOK { + c.JSON(http.StatusServiceUnavailable, body) + return + } + c.JSON(http.StatusOK, body) +} + +func boolToStatus(ok bool) string { + if ok { + return "connected" + } + return "disconnected" +} diff --git a/internal/app/middleware/i18n.go b/internal/app/middleware/i18n.go new file mode 100644 index 0000000..ac22722 --- /dev/null +++ b/internal/app/middleware/i18n.go @@ -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() + } +} diff --git a/internal/app/middleware/ratelimit_redis.go b/internal/app/middleware/ratelimit_redis.go new file mode 100644 index 0000000..8fa6a54 --- /dev/null +++ b/internal/app/middleware/ratelimit_redis.go @@ -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() + } +} diff --git a/internal/app/middleware/tenant.go b/internal/app/middleware/tenant.go index fdec1dd..a46f318 100644 --- a/internal/app/middleware/tenant.go +++ b/internal/app/middleware/tenant.go @@ -3,20 +3,20 @@ package middleware import ( "net/http" - "mengstack/internal/kernel/response" "mengstack/internal/kernel/errors" + "mengstack/internal/kernel/response" "mengstack/internal/kernel/tenant" "github.com/gin-gonic/gin" ) -func MultiTenant() gin.HandlerFunc { +func MultiTenant(resolver tenant.Resolver) gin.HandlerFunc { return func(c *gin.Context) { - tenantID := c.GetHeader("X-Tenant-ID") - if tenantID == "" { - tenantID = c.Query("tenant_id") - } - if tenantID == "" { + headerVal := c.GetHeader("X-Tenant-ID") + queryVal := c.Query("tenant_id") + + tenantID, ok := resolver.Resolve(headerVal, queryVal) + if !ok { response.Fail(c, errors.ErrTenantRequired) c.Abort() return @@ -30,13 +30,13 @@ func MultiTenant() gin.HandlerFunc { } } -func OptionalTenant() gin.HandlerFunc { +func OptionalTenant(resolver tenant.Resolver) gin.HandlerFunc { return func(c *gin.Context) { - tenantID := c.GetHeader("X-Tenant-ID") - if tenantID == "" { - tenantID = c.Query("tenant_id") - } - if tenantID != "" { + headerVal := c.GetHeader("X-Tenant-ID") + queryVal := c.Query("tenant_id") + + tenantID, ok := resolver.Resolve(headerVal, queryVal) + if ok { c.Set("tenant_id", tenantID) ctx := tenant.WithTenantID(c.Request.Context(), tenantID) c.Request = c.Request.WithContext(ctx) diff --git a/internal/app/migrate/migrate.go b/internal/app/migrate/migrate.go new file mode 100644 index 0000000..387a02b --- /dev/null +++ b/internal/app/migrate/migrate.go @@ -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 +} diff --git a/internal/app/upgrade/README.md b/internal/app/upgrade/README.md new file mode 100644 index 0000000..357c7ff --- /dev/null +++ b/internal/app/upgrade/README.md @@ -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" +} +``` diff --git a/internal/app/upgrade/handler.go b/internal/app/upgrade/handler.go new file mode 100644 index 0000000..ea69802 --- /dev/null +++ b/internal/app/upgrade/handler.go @@ -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) +} diff --git a/internal/app/upgrade/upgrade.go b/internal/app/upgrade/upgrade.go new file mode 100644 index 0000000..d2f76d7 --- /dev/null +++ b/internal/app/upgrade/upgrade.go @@ -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) +} diff --git a/internal/app/version/version.go b/internal/app/version/version.go new file mode 100644 index 0000000..0fbddfa --- /dev/null +++ b/internal/app/version/version.go @@ -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, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index cf605fd..adc19e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,8 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Log LogConfig `mapstructure:"log"` Plugin PluginConfig `mapstructure:"plugin"` + Tenant TenantConfig `mapstructure:"tenant"` + Storage StorageConfig `mapstructure:"storage"` } type ServerConfig struct { @@ -53,6 +55,17 @@ type PluginConfig struct { Enabled []string `mapstructure:"enabled"` } +type TenantConfig struct { + Mode string `mapstructure:"mode"` + DefaultID string `mapstructure:"default_id"` +} + +type StorageConfig struct { + Driver string `mapstructure:"driver"` + RootDir string `mapstructure:"root_dir"` + BaseURL string `mapstructure:"base_url"` +} + func Load() (*Config, error) { v := viper.New() v.SetConfigName("config") @@ -100,6 +113,11 @@ func setDefaults(v *viper.Viper) { v.SetDefault("jwt.issuer", "mengstack") v.SetDefault("log.level", "info") v.SetDefault("log.format", "json") + v.SetDefault("tenant.mode", "multi") + v.SetDefault("tenant.default_id", "00000000-0000-0000-0000-000000000001") + v.SetDefault("storage.driver", "local") + v.SetDefault("storage.root_dir", "./uploads") + v.SetDefault("storage.base_url", "http://localhost:8080") } func mergeEnvConfig(v *viper.Viper, env string) { diff --git a/internal/kernel/cache/cache.go b/internal/kernel/cache/cache.go new file mode 100644 index 0000000..ba69ebd --- /dev/null +++ b/internal/kernel/cache/cache.go @@ -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 +} diff --git a/internal/kernel/eventbus/bus.go b/internal/kernel/eventbus/bus.go new file mode 100644 index 0000000..d9e9252 --- /dev/null +++ b/internal/kernel/eventbus/bus.go @@ -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) +} diff --git a/internal/kernel/i18n/context.go b/internal/kernel/i18n/context.go new file mode 100644 index 0000000..73210ea --- /dev/null +++ b/internal/kernel/i18n/context.go @@ -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 +} diff --git a/internal/kernel/i18n/embed.go b/internal/kernel/i18n/embed.go new file mode 100644 index 0000000..01e98a2 --- /dev/null +++ b/internal/kernel/i18n/embed.go @@ -0,0 +1,6 @@ +package i18n + +import "embed" + +//go:embed messages/*.json +var Files embed.FS diff --git a/internal/kernel/i18n/i18n.go b/internal/kernel/i18n/i18n.go new file mode 100644 index 0000000..102533c --- /dev/null +++ b/internal/kernel/i18n/i18n.go @@ -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 +} diff --git a/internal/kernel/i18n/messages/en.json b/internal/kernel/i18n/messages/en.json new file mode 100644 index 0000000..62ab4d6 --- /dev/null +++ b/internal/kernel/i18n/messages/en.json @@ -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" +} diff --git a/internal/kernel/i18n/messages/zh.json b/internal/kernel/i18n/messages/zh.json new file mode 100644 index 0000000..417a85b --- /dev/null +++ b/internal/kernel/i18n/messages/zh.json @@ -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": "无效的令牌" +} diff --git a/internal/kernel/jobs/scheduler.go b/internal/kernel/jobs/scheduler.go new file mode 100644 index 0000000..5a35c3f --- /dev/null +++ b/internal/kernel/jobs/scheduler.go @@ -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() +} diff --git a/internal/kernel/plugin/.spec/mengplugin-format.md b/internal/kernel/plugin/.spec/mengplugin-format.md new file mode 100644 index 0000000..9708094 --- /dev/null +++ b/internal/kernel/plugin/.spec/mengplugin-format.md @@ -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 非对称签名 diff --git a/internal/kernel/plugin/.spec/module.md b/internal/kernel/plugin/.spec/module.md new file mode 100644 index 0000000..09e3633 --- /dev/null +++ b/internal/kernel/plugin/.spec/module.md @@ -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//` 创建 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 任何插件代码 diff --git a/internal/kernel/plugin/manifest.go b/internal/kernel/plugin/manifest.go new file mode 100644 index 0000000..131d32f --- /dev/null +++ b/internal/kernel/plugin/manifest.go @@ -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"` +} diff --git a/internal/kernel/plugin/plugin.go b/internal/kernel/plugin/plugin.go index 2278f90..2a41c99 100644 --- a/internal/kernel/plugin/plugin.go +++ b/internal/kernel/plugin/plugin.go @@ -15,8 +15,11 @@ package plugin import ( + "io/fs" + "github.com/gin-gonic/gin" "go.uber.org/fx" + "mengstack/internal/kernel/tenant" ) // Metadata describes a plugin's identity. @@ -31,16 +34,25 @@ type Plugin interface { // Metadata returns the plugin's identity. Metadata() Metadata + // Permissions returns the plugin's declared permissions. + // The sandbox enforces these at runtime — operations not declared are rejected. + Permissions() Permissions + // FxOption returns the fx.Module for dependency injection. FxOption() fx.Option // SetupRoutes registers HTTP routes on the Gin engine. // authMW is the JWT authentication middleware. - SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc) + // tenantResolver resolves tenant ID from request headers/query. + SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) // Init is called after all dependencies are resolved and before // the HTTP server starts. Use it for background workers, warm-up // caches, or external service connections. // Returning nil indicates success. Init() error + + // MigrationsFS returns the plugin's embedded migration SQL files. + // Return nil if the plugin has no migrations. + MigrationsFS() fs.FS } diff --git a/internal/kernel/plugin/registry.go b/internal/kernel/plugin/registry.go index 25360e6..5518413 100644 --- a/internal/kernel/plugin/registry.go +++ b/internal/kernel/plugin/registry.go @@ -30,14 +30,20 @@ func All() []Plugin { } // InitAll initializes all registered plugins in order. -func InitAll(log *zap.Logger) error { +// Each plugin's permissions are registered in the sandbox, and Init() is +// wrapped with panic recovery so one plugin's failure doesn't crash the process. +func InitAll(log *zap.Logger, sandbox *Sandbox) error { for _, p := range All() { meta := p.Metadata() + perms := p.Permissions() + + sandbox.Register(meta.Name, perms) + log.Info("initializing plugin", zap.String("name", meta.Name), zap.String("version", meta.Version), ) - if err := p.Init(); err != nil { + if err := sandbox.SafeInit(p); err != nil { return fmt.Errorf("plugin %s init failed: %w", meta.Name, err) } } diff --git a/internal/kernel/plugin/sandbox.go b/internal/kernel/plugin/sandbox.go new file mode 100644 index 0000000..885502e --- /dev/null +++ b/internal/kernel/plugin/sandbox.go @@ -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 +} diff --git a/internal/kernel/plugin/sandbox_test.go b/internal/kernel/plugin/sandbox_test.go new file mode 100644 index 0000000..6baee21 --- /dev/null +++ b/internal/kernel/plugin/sandbox_test.go @@ -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 } diff --git a/internal/kernel/storage/local.go b/internal/kernel/storage/local.go new file mode 100644 index 0000000..ec52fac --- /dev/null +++ b/internal/kernel/storage/local.go @@ -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 +} diff --git a/internal/kernel/storage/store.go b/internal/kernel/storage/store.go new file mode 100644 index 0000000..5f8e34e --- /dev/null +++ b/internal/kernel/storage/store.go @@ -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 +} diff --git a/internal/kernel/storage/upload.go b/internal/kernel/storage/upload.go new file mode 100644 index 0000000..9250ca2 --- /dev/null +++ b/internal/kernel/storage/upload.go @@ -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() + } +} diff --git a/internal/kernel/tenant/resolver.go b/internal/kernel/tenant/resolver.go new file mode 100644 index 0000000..53a356d --- /dev/null +++ b/internal/kernel/tenant/resolver.go @@ -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 +} diff --git a/internal/kernel/tenant/scope.go b/internal/kernel/tenant/scope.go new file mode 100644 index 0000000..bd7a44a --- /dev/null +++ b/internal/kernel/tenant/scope.go @@ -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) + } +} diff --git a/internal/kernel/websocket/handler.go b/internal/kernel/websocket/handler.go new file mode 100644 index 0000000..4e396e0 --- /dev/null +++ b/internal/kernel/websocket/handler.go @@ -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) +} diff --git a/internal/kernel/websocket/hub.go b/internal/kernel/websocket/hub.go new file mode 100644 index 0000000..e766ef6 --- /dev/null +++ b/internal/kernel/websocket/hub.go @@ -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 +) diff --git a/internal/modules/audit/interfaces/routes.go b/internal/modules/audit/interfaces/routes.go index 294985e..a3cce71 100644 --- a/internal/modules/audit/interfaces/routes.go +++ b/internal/modules/audit/interfaces/routes.go @@ -2,18 +2,17 @@ package interfaces import ( "mengstack/internal/app/middleware" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/audit/application" auditinfra "mengstack/internal/modules/audit/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "go.uber.org/zap" - "gorm.io/gorm" ) -func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) { +func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) { audit := r.Group("/api/v1/audit") - audit.Use(authMW, middleware.MultiTenant()) + audit.Use(authMW, middleware.MultiTenant(tenantResolver)) { audit.GET("/logs", h.ListLogs) } @@ -25,9 +24,4 @@ var Module = fx.Module("audit", application.NewService, NewHandler, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := auditinfra.Migrate(db); err != nil { - log.Fatal("audit migration failed", zap.Error(err)) - } - }), ) diff --git a/internal/modules/auth/.spec/module.md b/internal/modules/auth/.spec/module.md new file mode 100644 index 0000000..791bf2f --- /dev/null +++ b/internal/modules/auth/.spec/module.md @@ -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 diff --git a/internal/modules/auth/application/service.go b/internal/modules/auth/application/service.go index 155c46d..dfb7e3e 100644 --- a/internal/modules/auth/application/service.go +++ b/internal/modules/auth/application/service.go @@ -155,6 +155,142 @@ func (s *Service) JWTSecret() string { return s.jwtCfg.Secret } +func (s *Service) ListUsers(ctx context.Context, page, pageSize int, search string, status *int) ([]domain.UserDTO, int64, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return nil, 0, errors.ErrTenantRequired + } + + users, total, err := s.repo.List(ctx, tenantID, page, pageSize, search, status) + if err != nil { + return nil, 0, errors.Wrap(err, "LIST_USERS_FAILED", "failed to list users", 500) + } + + dtos := make([]domain.UserDTO, len(users)) + for i, u := range users { + dtos[i] = domain.ToUserDTO(&u) + } + return dtos, total, nil +} + +func (s *Service) GetUser(ctx context.Context, id uint) (domain.UserDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.UserDTO{}, errors.ErrTenantRequired + } + + user, err := s.repo.FindByID(ctx, tenantID, id) + if err != nil { + return domain.UserDTO{}, errors.ErrUserNotFound + } + + return domain.ToUserDTO(user), nil +} + +func (s *Service) CreateUser(ctx context.Context, req domain.CreateUserRequest) (domain.UserDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.UserDTO{}, errors.ErrTenantRequired + } + + if _, err := s.repo.FindByEmail(ctx, tenantID, req.Email); err == nil { + return domain.UserDTO{}, errors.ErrUserExists + } else if err != gorm.ErrRecordNotFound { + return domain.UserDTO{}, errors.Wrap(err, "QUERY_USER_FAILED", "failed to check existing user", 500) + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12) + if err != nil { + return domain.UserDTO{}, errors.Wrap(err, "HASH_FAILED", "failed to hash password", 500) + } + + status := req.Status + if status == 0 { + status = 1 + } + + user := &domain.User{ + TenantID: tenantID, + Username: req.Username, + Email: req.Email, + Password: string(hash), + Nickname: req.Nickname, + Status: status, + } + + if err := s.repo.Create(ctx, user); err != nil { + return domain.UserDTO{}, errors.Wrap(err, "CREATE_USER_FAILED", "failed to create user", 500) + } + + return domain.ToUserDTO(user), nil +} + +func (s *Service) UpdateUser(ctx context.Context, id uint, req domain.UpdateUserRequest) (domain.UserDTO, error) { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return domain.UserDTO{}, errors.ErrTenantRequired + } + + user, err := s.repo.FindByID(ctx, tenantID, id) + if err != nil { + return domain.UserDTO{}, errors.ErrUserNotFound + } + + if req.Nickname != "" { + user.Nickname = req.Nickname + } + if req.Email != "" { + user.Email = req.Email + } + if req.Status != nil { + user.Status = *req.Status + } + + if err := s.repo.Update(ctx, user); err != nil { + return domain.UserDTO{}, errors.Wrap(err, "UPDATE_USER_FAILED", "failed to update user", 500) + } + + return domain.ToUserDTO(user), nil +} + +func (s *Service) DeleteUser(ctx context.Context, id uint) error { + tenantID, ok := tenant.FromContext(ctx) + if !ok { + return errors.ErrTenantRequired + } + + if _, err := s.repo.FindByID(ctx, tenantID, id); err != nil { + return errors.ErrUserNotFound + } + + if err := s.repo.Delete(ctx, tenantID, id); err != nil { + return errors.Wrap(err, "DELETE_USER_FAILED", "failed to delete user", 500) + } + + return nil +} + +func (s *Service) UserCount(ctx context.Context, tenantID string) (int64, error) { + return s.repo.Count(ctx, tenantID) +} + +func (s *Service) UserCountAll(ctx context.Context) (int64, error) { + return s.repo.CountAll(ctx) +} + +func (s *Service) ListAllUsers(ctx context.Context, page, pageSize int) ([]domain.UserDTO, int64, error) { + users, total, err := s.repo.ListAll(ctx, page, pageSize) + if err != nil { + return nil, 0, errors.Wrap(err, "LIST_USERS_FAILED", "failed to list users", 500) + } + + dtos := make([]domain.UserDTO, len(users)) + for i, u := range users { + dtos[i] = domain.ToUserDTO(&u) + } + return dtos, total, nil +} + func (s *Service) generateTokens(user *domain.User) (domain.TokenPair, error) { access, err := GenerateToken(user.ID, user.TenantID, "access", time.Duration(s.jwtCfg.AccessExpiryMinutes)*time.Minute, diff --git a/internal/modules/auth/domain/dto.go b/internal/modules/auth/domain/dto.go index ca5a405..28f2258 100644 --- a/internal/modules/auth/domain/dto.go +++ b/internal/modules/auth/domain/dto.go @@ -24,17 +24,18 @@ type TokenPair struct { } type UserDTO struct { - ID uint `json:"id"` - TenantID string `json:"tenant_id"` - Username string `json:"username"` - Email string `json:"email"` - Nickname string `json:"nickname"` - Avatar string `json:"avatar"` - Status int `json:"status"` + ID uint `json:"id"` + TenantID string `json:"tenant_id"` + Username string `json:"username"` + Email string `json:"email"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Status int `json:"status"` + LastLogin string `json:"last_login,omitempty"` } func ToUserDTO(u *User) UserDTO { - return UserDTO{ + dto := UserDTO{ ID: u.ID, TenantID: u.TenantID, Username: u.Username, @@ -43,4 +44,22 @@ func ToUserDTO(u *User) UserDTO { Avatar: u.Avatar, Status: u.Status, } + if u.LastLogin != nil { + dto.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05") + } + return dto +} + +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=64"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8,max=128"` + Nickname string `json:"nickname"` + Status int `json:"status"` +} + +type UpdateUserRequest struct { + Nickname string `json:"nickname"` + Email string `json:"email"` + Status *int `json:"status"` } diff --git a/internal/modules/auth/domain/repository.go b/internal/modules/auth/domain/repository.go index aec1271..97fc61b 100644 --- a/internal/modules/auth/domain/repository.go +++ b/internal/modules/auth/domain/repository.go @@ -8,4 +8,9 @@ type UserRepository interface { FindByEmail(ctx context.Context, tenantID string, email string) (*User, error) FindByUsername(ctx context.Context, tenantID string, username string) (*User, error) Update(ctx context.Context, user *User) error + List(ctx context.Context, tenantID string, page, pageSize int, search string, status *int) ([]User, int64, error) + Delete(ctx context.Context, tenantID string, id uint) error + Count(ctx context.Context, tenantID string) (int64, error) + CountAll(ctx context.Context) (int64, error) + ListAll(ctx context.Context, page, pageSize int) ([]User, int64, error) } diff --git a/internal/modules/auth/infrastructure/user_repo.go b/internal/modules/auth/infrastructure/user_repo.go index 58bd992..d751ae8 100644 --- a/internal/modules/auth/infrastructure/user_repo.go +++ b/internal/modules/auth/infrastructure/user_repo.go @@ -50,3 +50,61 @@ func (r *userRepo) FindByUsername(ctx context.Context, tenantID string, username func (r *userRepo) Update(ctx context.Context, user *domain.User) error { return r.db.WithContext(ctx).Save(user).Error } + +func (r *userRepo) List(ctx context.Context, tenantID string, page, pageSize int, search string, status *int) ([]domain.User, int64, error) { + var users []domain.User + var total int64 + + q := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID) + + if search != "" { + like := "%" + search + "%" + q = q.Where("username LIKE ? OR email LIKE ? OR nickname LIKE ?", like, like, like) + } + if status != nil { + q = q.Where("status = ?", *status) + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := q.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func (r *userRepo) Delete(ctx context.Context, tenantID string, id uint) error { + return r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&domain.User{}).Error +} + +func (r *userRepo) Count(ctx context.Context, tenantID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error + return count, err +} + +func (r *userRepo) CountAll(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&count).Error + return count, err +} + +func (r *userRepo) ListAll(ctx context.Context, page, pageSize int) ([]domain.User, int64, error) { + var users []domain.User + var total int64 + + if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := r.db.WithContext(ctx).Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} diff --git a/internal/modules/auth/interfaces/handler.go b/internal/modules/auth/interfaces/handler.go index a9e80f7..76f2a60 100644 --- a/internal/modules/auth/interfaces/handler.go +++ b/internal/modules/auth/interfaces/handler.go @@ -1,6 +1,8 @@ package interfaces import ( + "strconv" + "mengstack/internal/kernel/response" "mengstack/internal/modules/auth/application" "mengstack/internal/modules/auth/domain" @@ -164,3 +166,143 @@ func (h *Handler) GetProfile(c *gin.Context) { func (h *Handler) Ping(c *gin.Context) { response.Success(c, gin.H{"message": "pong"}) } + +// ListUsers godoc +// @Summary 用户列表 +// @Description 分页获取当前租户下的用户列表,支持搜索和状态过滤 +// @Tags Users +// @Produce json +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Param search query string false "搜索关键词" +// @Param status query int false "状态过滤" +// @Success 200 {object} response.Response +// @Security Bearer +// @Security TenantID +// @Router /users [get] +func (h *Handler) ListUsers(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + search := c.Query("search") + + var status *int + if s := c.Query("status"); s != "" { + v, _ := strconv.Atoi(s) + status = &v + } + + dtos, total, err := h.svc.ListUsers(c.Request.Context(), page, pageSize, search, status) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, gin.H{"items": dtos, "total": total, "page": page}) +} + +// GetUser godoc +// @Summary 获取用户详情 +// @Tags Users +// @Produce json +// @Param id path int true "用户 ID" +// @Success 200 {object} response.Response{data=domain.UserDTO} +// @Security Bearer +// @Security TenantID +// @Router /users/{id} [get] +func (h *Handler) GetUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + dto, err := h.svc.GetUser(c.Request.Context(), uint(id)) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +// CreateUser godoc +// @Summary 创建用户 +// @Tags Users +// @Accept json +// @Produce json +// @Param request body domain.CreateUserRequest true "用户信息" +// @Success 200 {object} response.Response{data=domain.UserDTO} +// @Security Bearer +// @Security TenantID +// @Router /users [post] +func (h *Handler) CreateUser(c *gin.Context) { + var req domain.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.CreateUser(c.Request.Context(), req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +// UpdateUser godoc +// @Summary 更新用户 +// @Tags Users +// @Accept json +// @Produce json +// @Param id path int true "用户 ID" +// @Param request body domain.UpdateUserRequest true "更新内容" +// @Success 200 {object} response.Response{data=domain.UserDTO} +// @Security Bearer +// @Security TenantID +// @Router /users/{id} [put] +func (h *Handler) UpdateUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + var req domain.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, response.NewBadRequest("invalid request body")) + return + } + + dto, err := h.svc.UpdateUser(c.Request.Context(), uint(id), req) + if err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, dto) +} + +// DeleteUser godoc +// @Summary 删除用户 +// @Tags Users +// @Param id path int true "用户 ID" +// @Success 200 {object} response.Response +// @Security Bearer +// @Security TenantID +// @Router /users/{id} [delete] +func (h *Handler) DeleteUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.Fail(c, response.NewBadRequest("invalid user id")) + return + } + + if err := h.svc.DeleteUser(c.Request.Context(), uint(id)); err != nil { + response.HandleError(c, err) + return + } + + response.Success(c, nil) +} diff --git a/internal/modules/auth/interfaces/routes.go b/internal/modules/auth/interfaces/routes.go index c858a05..502e1a5 100644 --- a/internal/modules/auth/interfaces/routes.go +++ b/internal/modules/auth/interfaces/routes.go @@ -3,12 +3,12 @@ package interfaces import ( "mengstack/internal/app/middleware" "mengstack/internal/config" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/auth/application" "mengstack/internal/modules/auth/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "gorm.io/gorm" ) type AuthMiddleware struct { @@ -42,9 +42,9 @@ func NewAuthMiddleware(in AuthMiddleware) gin.HandlerFunc { } } -func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) { +func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) { auth := r.Group("/api/v1/auth") - auth.Use(middleware.OptionalTenant()) + auth.Use(middleware.OptionalTenant(tenantResolver)) { auth.POST("/register", h.Register) auth.POST("/login", h.Login) @@ -52,11 +52,17 @@ func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) { } protected := r.Group("/api/v1") - protected.Use(authMW, middleware.MultiTenant()) + protected.Use(authMW, middleware.MultiTenant(tenantResolver)) { protected.GET("/ping", h.Ping) protected.GET("/profile", h.GetProfile) protected.POST("/password", h.ChangePassword) + + protected.GET("/users", h.ListUsers) + protected.POST("/users", h.CreateUser) + protected.GET("/users/:id", h.GetUser) + protected.PUT("/users/:id", h.UpdateUser) + protected.DELETE("/users/:id", h.DeleteUser) } } @@ -77,7 +83,4 @@ var Module = fx.Module("auth", NewHandler, NewAuthMiddleware, ), - fx.Invoke(func(db *gorm.DB) error { - return infrastructure.Migrate(db) - }), ) diff --git a/internal/modules/dashboard/interfaces/handler.go b/internal/modules/dashboard/interfaces/handler.go new file mode 100644 index 0000000..f733dfd --- /dev/null +++ b/internal/modules/dashboard/interfaces/handler.go @@ -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 +} diff --git a/internal/modules/dashboard/interfaces/routes.go b/internal/modules/dashboard/interfaces/routes.go new file mode 100644 index 0000000..5416807 --- /dev/null +++ b/internal/modules/dashboard/interfaces/routes.go @@ -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) + }, + ), +) diff --git a/internal/modules/example/interfaces/plugin.go b/internal/modules/example/interfaces/plugin.go index 6ca86d4..d3dd29b 100644 --- a/internal/modules/example/interfaces/plugin.go +++ b/internal/modules/example/interfaces/plugin.go @@ -1,15 +1,16 @@ package interfaces import ( + "io/fs" + "mengstack/internal/app/middleware" "mengstack/internal/kernel/plugin" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/example/application" "mengstack/internal/modules/example/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "go.uber.org/zap" - "gorm.io/gorm" ) // ExamplePlugin implements plugin.Plugin. @@ -32,6 +33,17 @@ func (p *ExamplePlugin) Metadata() plugin.Metadata { } } +func (p *ExamplePlugin) Permissions() plugin.Permissions { + return plugin.Permissions{ + Database: []string{""}, // full database access (all tables) + Storage: false, // no file storage + Network: []string{}, // no network access + Events: []string{"*"}, // can emit any event + Routes: []string{"/api/v1/examples", "/api/v1/examples/*"}, + AdminPanel: false, + } +} + func (p *ExamplePlugin) FxOption() fx.Option { return fx.Module("example-plugin", fx.Provide( @@ -39,22 +51,21 @@ func (p *ExamplePlugin) FxOption() fx.Option { application.NewService, NewHandler, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("example plugin migration failed", zap.Error(err)) - } - }), ) } -func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc) { - g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant()) +func (p *ExamplePlugin) SetupRoutes(engine *gin.Engine, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) { + g := engine.Group("/api/v1/examples", authMW, middleware.MultiTenant(tenantResolver)) g.POST("", p.handler.Create) g.GET("", p.handler.List) g.GET("/:id", p.handler.Get) g.DELETE("/:id", p.handler.Delete) } +func (p *ExamplePlugin) MigrationsFS() fs.FS { + return nil +} + func (p *ExamplePlugin) Init() error { return nil } @@ -68,9 +79,4 @@ var Module = fx.Module("example-plugin", NewHandler, New, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("example plugin migration failed", zap.Error(err)) - } - }), ) diff --git a/internal/modules/notification/interfaces/routes.go b/internal/modules/notification/interfaces/routes.go index 622255b..5d1e77d 100644 --- a/internal/modules/notification/interfaces/routes.go +++ b/internal/modules/notification/interfaces/routes.go @@ -2,22 +2,22 @@ package interfaces import ( "mengstack/internal/app/middleware" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/notification/application" "mengstack/internal/modules/notification/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "go.uber.org/zap" - "gorm.io/gorm" ) func SetupRoutes( r *gin.Engine, h *Handler, authMW gin.HandlerFunc, + tenantResolver tenant.Resolver, ) { notifications := r.Group("/api/v1/notifications") - notifications.Use(authMW, middleware.MultiTenant()) + notifications.Use(authMW, middleware.MultiTenant(tenantResolver)) { notifications.POST("", h.Create) notifications.GET("/:id", h.Get) @@ -35,9 +35,4 @@ var Module = fx.Module("notification", application.NewService, NewHandler, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("notification migration failed", zap.Error(err)) - } - }), ) diff --git a/internal/modules/org/interfaces/routes.go b/internal/modules/org/interfaces/routes.go index 371b466..fea937d 100644 --- a/internal/modules/org/interfaces/routes.go +++ b/internal/modules/org/interfaces/routes.go @@ -2,18 +2,17 @@ package interfaces import ( "mengstack/internal/app/middleware" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/org/application" "mengstack/internal/modules/org/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "go.uber.org/zap" - "gorm.io/gorm" ) -func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) { +func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc, tenantResolver tenant.Resolver) { org := r.Group("/api/v1/org") - org.Use(authMW, middleware.MultiTenant()) + org.Use(authMW, middleware.MultiTenant(tenantResolver)) { org.POST("/tenants", h.CreateTenant) org.GET("/tenants", h.ListTenants) @@ -29,9 +28,4 @@ var Module = fx.Module("org", application.NewService, NewHandler, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("org migration failed", zap.Error(err)) - } - }), ) diff --git a/internal/modules/rbac/.spec/module.md b/internal/modules/rbac/.spec/module.md new file mode 100644 index 0000000..185134c --- /dev/null +++ b/internal/modules/rbac/.spec/module.md @@ -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 初始化,可被修改) diff --git a/internal/modules/rbac/interfaces/routes.go b/internal/modules/rbac/interfaces/routes.go index 5d2755d..0479bab 100644 --- a/internal/modules/rbac/interfaces/routes.go +++ b/internal/modules/rbac/interfaces/routes.go @@ -2,6 +2,7 @@ package interfaces import ( "mengstack/internal/app/middleware" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/rbac/application" "mengstack/internal/modules/rbac/infrastructure" rbacmiddleware "mengstack/internal/modules/rbac/middleware" @@ -16,9 +17,10 @@ func SetupRoutes( r *gin.Engine, h *Handler, authMW gin.HandlerFunc, + tenantResolver tenant.Resolver, ) { rbac := r.Group("/api/v1/rbac") - rbac.Use(authMW, middleware.MultiTenant()) + rbac.Use(authMW, middleware.MultiTenant(tenantResolver)) { rbac.GET("/permissions", h.ListPermissions) @@ -45,10 +47,7 @@ var Module = fx.Module("rbac", NewHandler, rbacmiddleware.NewPermissionMiddleware, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("rbac migration failed", zap.Error(err)) - } + fx.Invoke(func(log *zap.Logger, db *gorm.DB) { if err := infrastructure.Seed(db, log); err != nil { log.Error("rbac seed failed", zap.Error(err)) } diff --git a/internal/modules/settings/interfaces/routes.go b/internal/modules/settings/interfaces/routes.go index 34e11eb..d65dfa8 100644 --- a/internal/modules/settings/interfaces/routes.go +++ b/internal/modules/settings/interfaces/routes.go @@ -2,22 +2,22 @@ package interfaces import ( "mengstack/internal/app/middleware" + "mengstack/internal/kernel/tenant" "mengstack/internal/modules/settings/application" "mengstack/internal/modules/settings/infrastructure" "github.com/gin-gonic/gin" "go.uber.org/fx" - "go.uber.org/zap" - "gorm.io/gorm" ) func SetupRoutes( r *gin.Engine, h *Handler, authMW gin.HandlerFunc, + tenantResolver tenant.Resolver, ) { settings := r.Group("/api/v1/settings") - settings.Use(authMW, middleware.MultiTenant()) + settings.Use(authMW, middleware.MultiTenant(tenantResolver)) { settings.GET("", h.ListGlobalSettings) settings.GET("/global/:key", h.GetGlobalSetting) @@ -36,9 +36,4 @@ var Module = fx.Module("settings", application.NewService, NewHandler, ), - fx.Invoke(func(db *gorm.DB, log *zap.Logger) { - if err := infrastructure.Migrate(db); err != nil { - log.Fatal("settings migration failed", zap.Error(err)) - } - }), ) diff --git a/internal/testutil/db.go b/internal/testutil/db.go new file mode 100644 index 0000000..e1beb1b --- /dev/null +++ b/internal/testutil/db.go @@ -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 +} diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go new file mode 100644 index 0000000..396bac5 --- /dev/null +++ b/internal/testutil/fixtures.go @@ -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) +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go new file mode 100644 index 0000000..7668ed4 --- /dev/null +++ b/internal/testutil/server.go @@ -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 +} diff --git a/migrations/000002_add_rbac.down.sql b/migrations/000002_add_rbac.down.sql new file mode 100644 index 0000000..37c7e11 --- /dev/null +++ b/migrations/000002_add_rbac.down.sql @@ -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; diff --git a/migrations/000002_add_rbac.up.sql b/migrations/000002_add_rbac.up.sql new file mode 100644 index 0000000..dc83c71 --- /dev/null +++ b/migrations/000002_add_rbac.up.sql @@ -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) +); diff --git a/migrations/000003_add_audit_logs.down.sql b/migrations/000003_add_audit_logs.down.sql new file mode 100644 index 0000000..acb2224 --- /dev/null +++ b/migrations/000003_add_audit_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS audit_logs; diff --git a/migrations/000003_add_audit_logs.up.sql b/migrations/000003_add_audit_logs.up.sql new file mode 100644 index 0000000..d629ac8 --- /dev/null +++ b/migrations/000003_add_audit_logs.up.sql @@ -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); diff --git a/migrations/000004_add_settings.down.sql b/migrations/000004_add_settings.down.sql new file mode 100644 index 0000000..4596c6a --- /dev/null +++ b/migrations/000004_add_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS settings; diff --git a/migrations/000004_add_settings.up.sql b/migrations/000004_add_settings.up.sql new file mode 100644 index 0000000..7759ace --- /dev/null +++ b/migrations/000004_add_settings.up.sql @@ -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); diff --git a/migrations/000005_add_notifications.down.sql b/migrations/000005_add_notifications.down.sql new file mode 100644 index 0000000..d5d18e1 --- /dev/null +++ b/migrations/000005_add_notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS notifications; diff --git a/migrations/000005_add_notifications.up.sql b/migrations/000005_add_notifications.up.sql new file mode 100644 index 0000000..7ef983e --- /dev/null +++ b/migrations/000005_add_notifications.up.sql @@ -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); diff --git a/migrations/000006_add_examples.down.sql b/migrations/000006_add_examples.down.sql new file mode 100644 index 0000000..8845378 --- /dev/null +++ b/migrations/000006_add_examples.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS examples; diff --git a/migrations/000006_add_examples.up.sql b/migrations/000006_add_examples.up.sql new file mode 100644 index 0000000..4711842 --- /dev/null +++ b/migrations/000006_add_examples.up.sql @@ -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); diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..381665a --- /dev/null +++ b/migrations/embed.go @@ -0,0 +1,6 @@ +package migrations + +import "embed" + +//go:embed *.sql +var Files embed.FS diff --git a/pkg/query/query.go b/pkg/query/query.go new file mode 100644 index 0000000..ed05412 --- /dev/null +++ b/pkg/query/query.go @@ -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"` +} diff --git a/tests/benchmarks/auth_bench_test.go b/tests/benchmarks/auth_bench_test.go new file mode 100644 index 0000000..00b4621 --- /dev/null +++ b/tests/benchmarks/auth_bench_test.go @@ -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) + } + } + }) + } +} diff --git a/tests/benchmarks/middleware_bench_test.go b/tests/benchmarks/middleware_bench_test.go new file mode 100644 index 0000000..3b4d7c6 --- /dev/null +++ b/tests/benchmarks/middleware_bench_test.go @@ -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) + } +} diff --git a/tests/integration/auth_test.go b/tests/integration/auth_test.go new file mode 100644 index 0000000..165102f --- /dev/null +++ b/tests/integration/auth_test.go @@ -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) +}