feat: 优化web
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *我使用的是 pnpm)",
|
||||
"Bash(npx next *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
---
|
||||
name: Next 全量对接 Go API
|
||||
overview: 全栈对接 Go API;OAuth2 Code + PKCE;**401/403 与 200+`code` 分工**(**含:密码错=401,只看状态**);JSON 信封;login 的 401 不 refresh;dev 跨域;登出多 Tab;auth/login 改造 §3;租户关 Tabs;路由守卫。
|
||||
todos:
|
||||
- id: init-next-env
|
||||
content: 初始化 Next+TS+Prettier,配置 NEXT_PUBLIC_API_ORIGIN 与目录结构(api/、stores/)
|
||||
status: completed
|
||||
- id: api-client-auth
|
||||
content: 实现 apiClient(JSON + Bearer;OAuth token 用 form);auth/login 取 code 再 oauth/token;logout;Zustand useAuthStore
|
||||
status: completed
|
||||
- id: go-auth-login-pkce
|
||||
content: Go:改造 POST /api/v1/auth/login 密码通过后签发 PKCE 绑定 code,与 CreateAuthorizationCode+token 复用;文档 oauth-v2 对齐
|
||||
status: completed
|
||||
- id: api-iam-system
|
||||
content: 按 http_register 补齐 iam(tenant/dept/role/user/menu)与 system/param 全部封装
|
||||
status: completed
|
||||
- id: oauth-pkce-ui
|
||||
content: 实现 OAuth2 PKCE 授权链接、callback 页、换 token 与 token 刷新策略
|
||||
status: completed
|
||||
- id: cors-cookie
|
||||
content: 与 Go 对齐 CORS、Cookie SameSite/credentials;生产 HTTPS 检查清单
|
||||
status: completed
|
||||
- id: layout-shell-nav
|
||||
content: AppShell;顶栏用户下拉;侧栏后端 nav;经典/图标+localStorage;path 映射路由(首版 web 为顶栏+入口,侧栏与双模式待迭代)
|
||||
status: completed
|
||||
- id: workbench-tabs-content
|
||||
content: 主工作区 Tabs+页签状态+内容区(首版 TabStrip 骨架;右键/滚动钮/标准列表/左树右表待迭代)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Next.js 前端全量对接 Go 后端方案
|
||||
|
||||
## 后端路由清单(对接范围)
|
||||
|
||||
以下均基于当前仓库中的注册代码([`internal/auth/http_register.go`](internal/auth/http_register.go)、[`internal/iam/http_register.go`](internal/iam/http_register.go)、[`internal/system/http_register.go`](internal/system/http_register.go)、[`internal/server/http.go`](internal/server/http.go))。
|
||||
|
||||
| 前缀 | 说明 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET /health` | 探活 |
|
||||
| **根路径 OAuth** | `GET /oauth/authorize`、`POST /oauth/token`、`POST /oauth/introspect`(**无** `/api/v1` 前缀) |
|
||||
| **`/api/v1`** | 全组挂载 Bearer 中间件(无 token 仍放行,见 [`internal/auth/middleware/bearer.go`](internal/auth/middleware/bearer.go)) |
|
||||
|
||||
**`/api/v1` 下具体接口:**
|
||||
|
||||
- **Auth**:`POST /api/v1/auth/login`、`POST /api/v1/auth/logout`
|
||||
- **IAM**([`internal/iam/http_register.go`](internal/iam/http_register.go)):`/api/v1/iam/tenant/*`、`/dept/*`、`/role/*`、`/user/*`、`/menu/*`(含 `tree`、`nav`、`perms` 等)
|
||||
- **System**:`/api/v1/system/param/*`(create/update/delete-batch/get/list)
|
||||
|
||||
前端需要为上述路径提供 **baseURL 配置**(例如 `NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000`),并区分两类调用:
|
||||
|
||||
- **业务 JSON API**:`fetch(\`${origin}/api/v1/...\`)`
|
||||
- **OAuth**:`fetch(\`${origin}/oauth/token\`, …)`等(表单`application/x-www-form-urlencoded`)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph next [Nextjs]
|
||||
Pages[页面与Radix组件]
|
||||
Store[Zustand状态]
|
||||
Client[api客户端层]
|
||||
end
|
||||
subgraph go [Go]
|
||||
Health["/health"]
|
||||
OAuth["/oauth/*"]
|
||||
API["/api/v1/*"]
|
||||
end
|
||||
Pages --> Store
|
||||
Store --> Client
|
||||
Client --> Health
|
||||
Client --> OAuth
|
||||
Client --> API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 架构建议
|
||||
|
||||
### 1. 环境与请求基址
|
||||
|
||||
- 使用 `NEXT_PUBLIC_API_ORIGIN`(仅 scheme+host+port,**不要**带尾部 `/api/v1`,便于拼 `/api/v1` 与 `/oauth`)。
|
||||
- **部署**:**上线后** Next 与 Go **同一站点/同域**(或反代为同源),Cookie(若仍用于 authorize 兼容)与 CSRF 更简单。**开发阶段** 常为 **前端 localhost:3000 + 后端 :8000 跨域**,Go 必须配置 **CORS 白名单**(具体 `http://localhost:3000` 等),`Access-Control-Allow-Origin` **禁止 `*`** 若带 `credentials`;Bearer 主路径下跨域以 **`Authorization`** 为主,但仍需为 **预检 OPTIONS** 与 **错误 JSON** 配好 CORS。
|
||||
|
||||
### 2. HTTP 客户端层(对接「所有接口」的核心)
|
||||
|
||||
- 封装单一 **`apiClient`**(`fetch` 或 `ky`/`axios` 二选一),职责:
|
||||
- 统一 `baseURL`、`Content-Type: application/json`(OAuth token 端点单独走 **form-urlencoded**)。
|
||||
- 从 Zustand(或内存)读取 **access_token**,对 `/api/v1/**` 自动加 `Authorization: Bearer <opaque>`。
|
||||
- **HTTP 401**:**先 refresh**(见 §3),失败再 **弹窗**;**HTTP 200** 时读 **`body.code`** 判断业务成败(见 §「JSON 统一响应」)。
|
||||
- 可选:`X-Tenant-ID` 等与后端 [`internal/iam/handler/helpers.go`](internal/iam/handler/helpers.go) 兼容。
|
||||
- **按领域拆模块**(与后端包对齐,便于维护):
|
||||
- `api/auth.ts` — login/logout
|
||||
- `api/oauth.ts` — token、(若前端自己做 PKCE)authorize URL 构造
|
||||
- `api/iam/tenant.ts`、`dept.ts`、`role.ts`、`user.ts`、`menu.ts`
|
||||
- `api/system/param.ts`
|
||||
|
||||
这样「对接所有接口」= **模块方法覆盖上表每一条路由**,类型用 TypeScript 手写 DTO(后端暂无统一 OpenAPI 时可从 handler 结构体对齐,后续可加 swagger 生成)。
|
||||
|
||||
**JSON 统一响应(`/api/v1/**`业务接口,含改造后的`auth/login`)\*\*
|
||||
|
||||
**HTTP 状态码(已定)**
|
||||
|
||||
| HTTP | 含义 |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`401`** | **认证失败**:未登录、token 无效/过期、**以及 `POST /api/v1/auth/login` 账号密码错误(凭据不成立)**等。此类场景 **只看 HTTP 状态即可**;**不必**再依赖响应体 `code` 区分是否密码错误(体可仍带统一信封或极简 JSON,供文案时选用)。 |
|
||||
| **`403`** | **已认证但无权限访问**该资源或操作(**无全权限/禁止访问**)。 |
|
||||
| **`200`** | **其余业务层响应**(含非认证类校验失败、参数错误、业务规则不满足等)。**具体失败类型看响应体 `code`**。**认证类失败(含密码错误)不归入此类**,见上 **`401`**。 |
|
||||
| **`5xx`** | **服务端异常**(可选)仍用 **5xx**,可与信封并存或单独约定;**不**要求强行改成 200。 |
|
||||
|
||||
**响应体信封**
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`code`** | **业务码**:**`200` 表示业务成功**;非 `200` 表示**在 HTTP=200 前提下**的业务失败(参数错、业务规则不满足等)。**凭据/认证问题已用 HTTP `401` 表达时,前端以 HTTP 为准**,可不解析 `code`。 |
|
||||
| **`msg`** | 文案,供 toast。 |
|
||||
| **`data`** | 成功时为载荷;失败时 **`null` 或省略**。 |
|
||||
|
||||
- 请求可传 **`state`**,成功时经 **`data` 回显**。
|
||||
- **`/oauth/token`**、**`/oauth/introspect`** 等 OAuth 端点可仍按 **RFC**,**以 `docs/oauth-v2.md` 为准**(可与业务信封并存)。
|
||||
|
||||
**前端 `apiClient`**:先读 **HTTP**;若为 **`401`** → **若请求不是「正在调 `/auth/login`」**则 **先 refresh**,失败再弹窗;**若 401 来自 `POST /auth/login`(含密码错误)** → **不 refresh**,直接 **弹窗或提示**(与 token 无关)。**`403`** → toast。**`200`** → 解析 **`body.code`**。
|
||||
|
||||
### 3. 登录与鉴权策略
|
||||
|
||||
**Cookie 登录 vs OAuth2 Code + PKCE(概念)**
|
||||
|
||||
- **Cookie 会话**:`Set-Cookie` 会话 id,后续请求自动带 Cookie。
|
||||
- **OAuth2 Code + PKCE**:用 **`code_verifier` / `code_challenge(S256)`** 绑定授权码,再通过 **`POST /oauth/token`**(`grant_type=authorization_code`)换 **opaque `access_token`**;业务请求 **`Authorization: Bearer`**。见 [`docs/oauth-v2.md`](docs/oauth-v2.md)。
|
||||
|
||||
**现状与目标(后端)**
|
||||
|
||||
- **现状(偏差)**:[`internal/auth/handler/login.go`](internal/auth/handler/login.go) 中 `POST /api/v1/auth/login` 仅校验账号密码并下发 **Session Cookie**,返回 `{"ok":true}`,**未**纳入 PKCE,也**未**与 [`internal/auth/oauth2/service.go`](internal/auth/oauth2/service.go) 的授权码/token 管线统一。
|
||||
- **目标(已定需求)**:**`/api/v1/auth/login` 也必须走 OAuth2 Code + PKCE 语义**——即:账号密码验证成功后,**签发可与现有 `/oauth/token` 交换的 authorization_code(且绑定 PKCE)**,前端仍用 **同一套 `POST /oauth/token`** 换 Bearer;**禁止**仅靠 Session Cookie 作为 SPA 主鉴权路径(Session 若保留,仅作与 `/oauth/authorize` 浏览器跳转兼容的可选项,可逐步弱化)。
|
||||
|
||||
**`client_id` / `redirect_uri` 是什么?为何常和「种子 SPA」绑在一起?**
|
||||
|
||||
- **`client_id`**:OAuth2 里标识 **「哪一个客户端应用」**(例如浏览器里的 SPA)。授权服务器(Go)在库里 **登记过** 的 `client_id` 才合法。
|
||||
- **`redirect_uri`**:换发 **`authorization_code` 之后**,用户(或纯 API 流程)**最终要把 `code` 送到的回调地址**。为防止劫持,**必须与该 `client_id` 在服务端登记的允许列表一致**(`ParseRedirectURIs` / `RedirectURIMatch`)。
|
||||
- **「种子 SPA」**:指迁移/安装时 **预置的一条 OAuth 客户端**(如 `client_id=spa`,`redirect_uri` 含 `http://localhost:3000/oauth/callback` 与生产 `https://app.example.com/oauth/callback`)。**前端在 dev/prod 可共用同一 `client_id`**,只要 **各环境 `redirect_uri` 都已登记**;若种子只含一条 URI,开发跨域时需在 **后端配置/数据库** 里 **补登记** `localhost` 回调,否则 token 交换会报 `invalid_redirect_uri`。
|
||||
|
||||
**推荐后端形态(与现有 Token 端复用)**
|
||||
|
||||
1. 扩展 `POST /api/v1/auth/login` 请求体:除 `user_name`、`password`、`tenant_id` 外,携带 **`code_challenge`、`code_challenge_method=S256`、`client_id`、`redirect_uri`**;**可选 `state`**(成功时在响应 `data` 中原样返回)。须通过 **与 `/oauth/authorize` 相同**的 `client_id` / `redirect_uri` 校验。
|
||||
2. 校验通过后,写入 **与 authorize 相同**的 authorization_code(同一 `oauth2.Store`、PKCE 绑定)。**HTTP 200**,body:`{ "code": 200, "msg": "操作成功", "data": { "authorization_code": "<code>", "state": "<echo>" } }`(**`authorization_code` 与 `/oauth/token` 的 `code` 同义**)。
|
||||
3. **`/api/v1/auth/login` 密码错误**:**`HTTP 401`**(认证失败),前端 **只认状态码** 走统一 401 处理链(与 token 失效同类;**不在此用 `HTTP 200` + `body.code` 表示密码错**)。**参数不合法等非认证问题**:**`HTTP 200`** + `code`≠200 等业务码(与上表一致)。
|
||||
4. 前端拿到 `authorization_code` 后 **`POST /oauth/token`**(form)换 **access_token / refresh_token**;**前端主路径不依赖 Session Cookie**(`Set-Cookie` 可选保留)。
|
||||
|
||||
**Go 改造落点(备忘)**:在 `LoginHandler` 或抽取的 OAuth 服务方法中复用 `oauth2.Service` / `Store` 的 **授权码创建**能力;注意 **rate limit**、**redirect_uri 校验**、**client_id** 与种子 SPA 配置一致;更新 [`docs/oauth-v2.md`](docs/oauth-v2.md) 中 **「JSON 登录」** 小节与示例请求/响应。
|
||||
|
||||
| 步骤 | 前端要点 |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 登录 | 生成 PKCE → **`POST /api/v1/auth/login`**(body 含挑战 + client + redirect_uri + 可选 **state**)→ 信封取 **`data.authorization_code`**。 |
|
||||
| 换 token | **`POST /oauth/token`**(form,`authorization_code` + `code_verifier`)→ **access_token / refresh_token**;与全站 OAuth 流程一致。 |
|
||||
| 调 API | `/api/v1/**` 带 **`Authorization: Bearer`**。 |
|
||||
| **静默刷新** | `access_token` 将过期或 API 返回 **401** 时,**先**用 **`refresh_token`** 调 `grant_type=refresh_token` **无感换票**;**仅当 refresh 也失败**(无 refresh、过期、服务端拒绝)再 **弹登录框**(再走 login→code→token)。`apiClient` 内建议 **单飞刷新队列**,避免并发请求重复 refresh。 |
|
||||
|
||||
**401 时的交互(已定)**:**先静默 refresh** → 仍 401 再弹窗;弹窗 **仅账号 + 密码** + PKCE;**`/api/v1/auth/login` → authorization_code → `/oauth/token`**;成功后 **关弹窗并重试原请求**。**用户主动「退出」**:`POST /api/v1/auth/logout`、清本地 token、**多 Tab** 可用 **`BroadcastChannel` / `localStorage` 事件** 同步登出(与 §4 一致)。
|
||||
|
||||
### 4. Zustand
|
||||
|
||||
- **`useAuthStore`**:`accessToken`、`refreshToken`(存前端或安全存储策略团队定)、`userId`/`tenantId`、`setTokens`、**`logout`**(清状态 + 调 `auth/logout` + 广播多 Tab)。
|
||||
- 可选:**`useTenantStore`** 与菜单/权限缓存,与 `iam/menu` 的 `nav`、`perms` 联动。
|
||||
|
||||
### 5. Next.js 注意点
|
||||
|
||||
- **App Router**:OAuth **callback**、首次引导授权等放在 `app/(auth)/...`;业务页在 `app/(dashboard)/...`。layout 可对「冷启动无 token」做跳转授权等策略;**业务 API 返回 401 时优先用全局弹窗登录**(见 §3),避免打断用户操作。
|
||||
- **服务端组件默认不能带浏览器 Cookie 调浏览器域下的私有 API** unless 使用 **Route Handler** 作 BFF;若希望「全 CSR + 直连 Go」,以 **Client Component + `apiClient`** 为主即可。
|
||||
- **Prettier**:与 ESLint 一起在仓库根配置,与现有 Go 仓库分属不同目录时各自一份配置即可。
|
||||
|
||||
### 6. Radix UI
|
||||
|
||||
- 用于表单、Dialog、Dropdown、**Navigation Menu / Collapsible**、**Tooltip**(图标模式下图标旁展示完整菜单名)等;与业务页面解耦。
|
||||
|
||||
---
|
||||
|
||||
## 导航数据:后端驱动(强制约定)
|
||||
|
||||
**目标**:左侧导航(含经典树状 / 图标模式下的浮层或抽屉)所展示的 **层级、顺序、可见项、展示名、图标字段(若后端提供)** 均以 **后端返回的菜单树为准**;前端 **不在生产环境写死业务菜单列表**(开发环境可保留极少量占位路由仅用于 Storybook/演示,与正式壳分离)。
|
||||
|
||||
**主数据源(与当前 Go 能力对齐)**
|
||||
|
||||
- **`GET /api/v1/iam/menu/nav`**:面向当前用户/租户的 **导航菜单树**(侧栏渲染首选)。
|
||||
- 必要时配合 **`GET /api/v1/iam/menu/perms`** 等做 **按钮级/路由级权限**;与 [`iam_menu`](migrations/postgres/001_iam.sql) 及角色绑定一致。
|
||||
- 若需全量配置态菜单(管理端「菜单管理」页),可用 **`/api/v1/iam/menu/tree`** 等接口;**运行时侧栏**仍以 **nav** 为主,避免混用两套源。
|
||||
|
||||
**前端职责**
|
||||
|
||||
- 登录成功且具备 token 后 **拉取 nav**(及 perms),存入 Zustand/React Query 等,带 **SWR/失效策略**(切换租户、重新登录时 **重新请求**)。
|
||||
- 将后端节点字段(如 `path`、`component`、`perms`、`children`)**映射到 Next `Link`/`router` 路径**;若后端 `path` 与前端路由表不一致,维护 **一层显式映射表**(仍由后端数据驱动「显示哪些项」,映射只解决 URL 形状)。
|
||||
- **经典模式**:直接渲染树组件;**图标模式**:同一棵树做遍历,父节点走浮层/抽屉(见上文「图标模式下的二级、三级导航」)。
|
||||
|
||||
**反模式(避免)**
|
||||
|
||||
- 侧栏 `MenuItem` 写死在 `layout.tsx` 内且与数据库菜单两套真相。
|
||||
- 仅首屏拉一次菜单后永不刷新(租户切换、权限变更会不同步)。
|
||||
|
||||
---
|
||||
|
||||
## 整体页面与布局(管理端壳层)
|
||||
|
||||
目标:登录后进入 **统一壳**(**顶栏** + **左侧导航** + **主内容区**),业务模块(租户/部门/角色/用户/菜单/系统参数等)均在主内容区切换;登录/回调页 **不使用** 该壳,避免多余导航。
|
||||
|
||||
| 区域 | 职责 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **顶栏** | 左侧:Logo/产品名;右侧:**用户信息区**(见下「顶栏右侧:用户信息下拉」,内含侧栏经典/图标切换)、租户等全局摘要(按需);**本阶段不在顶栏提供全局搜索框** |
|
||||
| **左导航** | 见下文「双模式」;**数据见上一节「导航数据:后端驱动」** |
|
||||
| **主工作区** | 见下文「主工作区:Tabs 区 + 内容区」;**非简单单页 children**,而是 **多页签 + 卡片式内容区**(列表/表单等在内层渲染) |
|
||||
|
||||
### 顶栏右侧:用户信息下拉(产品约定)
|
||||
|
||||
- **位置**:Header **最右侧**(或紧挨全局操作区)展示当前用户摘要(建议:**头像/占位图 + 显示名或账号**)。
|
||||
- **交互**:**鼠标悬停**在用户区域上时,展开 **下拉列表**(可用 Radix `DropdownMenu` 配合 `onOpenChange` / 延迟关闭,或 `HoverCard`+菜单组合;实现时注意 **触控设备无悬停**,需 **点击同样可打开**,并支持 **Esc 关闭、键盘方向键**,避免纯悬停导致无障碍与移动端不可用)。
|
||||
- **下拉项(固定项,顺序建议如下)**:
|
||||
1. **个人中心** — 跳转前端路由(如 `/account` 或 `/profile`),展示当前用户资料;**若后端暂无专用接口**,可先读已有用户接口(如按 `user_id` `GET`)或占位页,后续与 IAM 对齐。
|
||||
2. **修改密码** — 跳转 `/account/password` 或 **弹窗表单**;提交时调用后端修改密码接口(**若当前 Go 未暴露**,计划中单列为「需补接口」或与 `iam/user` 更新密码能力对齐)。
|
||||
3. **侧栏布局** — **经典模式** / **图标模式** 二选一(可用分段控件、单选行或两项可点菜单项),与 `useShellStore` 的 `sidebarMode` + `localStorage` 一致;**勿再单独放在顶栏**。
|
||||
4. **退出** — 调用 `POST /api/v1/auth/logout`,清除前端 token/状态并跳转登录页。
|
||||
|
||||
```text
|
||||
+------------------------------------------------------------------------+
|
||||
| [Logo] Smart Admin [租户…] [○ 张三 ▼] ← 悬停/点击展开 |
|
||||
+------------------------------------------------------------------------+
|
||||
+---------------------------+
|
||||
| 个人中心 |
|
||||
| 修改密码 |
|
||||
| 侧栏:[ 经典 | 图标 ] |
|
||||
| 退出 |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主工作区:Tabs 区 + 内容区(产品约定)
|
||||
|
||||
导航栏 **右侧**为 **主工作区**,纵向分为两层:**上部 Tabs 页签区** + **下部内容区**。左侧菜单点击路由时,**优先在 Tabs 中打开/激活对应页签**(与常见「多标签后台」一致;实现可用 Zustand 维护页签列表与当前激活项)。
|
||||
|
||||
### A. Tabs 页签区
|
||||
|
||||
| 项 | 约定 |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **结构** | 顶部一条 **Tabs 栏**;每个 Tab 对应一个已打开页面(路由 + 关键参数可序列化进 tab id)。 |
|
||||
| **固定首页签** | **最左侧**固定 **「概览」** 页签(路由如 `/dashboard` 或 `/overview`),**不可关闭**、不可被「关闭全部」关闭。 |
|
||||
| **右键菜单** | 在 **页签条区域**(建议在 **某个 tab 标签上**)**点击右键**弹出菜单(Radix **Context Menu**),包含:**关闭**、**关闭左侧**、**关闭右侧**、**关闭全部**(关闭全部时保留「概览」)。 |
|
||||
| **横向溢出** | 当 Tab 过多超出可视宽度时:**左侧、右侧各一个常驻小按钮**(如 `‹` `›`),用于将 Tab 条 **向左/向右滚动**;按钮**始终占位可见**(禁用态亦可,避免布局跳动)。Tab 容器使用 `overflow-x: auto` + `scrollBy` 或等效实现。 |
|
||||
|
||||
**线框示意**
|
||||
|
||||
```text
|
||||
| ◀ | [ 概览 | 用户管理 × | 角色管理 × | ... ] | ▶ |
|
||||
^固定不可关^ ^右键关闭等^
|
||||
```
|
||||
|
||||
### B. 内容区(包裹在 Tabs 下方)
|
||||
|
||||
| 项 | 约定 |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **外层边距** | 内容区相对主工作区容器:**左、上、右** 外边距均为 **12px**;外层背景为 **浅灰**(具体色值用设计 token,如 `hsl` 中性灰,勿写死纯黑字对比不足)。 |
|
||||
| **内层卡片** | 实际承载页面的是 **白色背景** 容器;**内边距 12px**(与外边距统一节奏)。内容很短时,**底部**仍保持 **至少 12px** 外边距(可用 `min-height` + `padding-bottom` 或 flex 布局保证)。 |
|
||||
| **圆角** | 白色主工作卡片 **四角圆角**(建议 `8px`–`12px` 或 Tailwind `rounded-lg`/`rounded-xl`,全局一致)。 |
|
||||
|
||||
### C. 一般列表页(内容区内「标准模板」)
|
||||
|
||||
适用于租户/用户/角色等 **表格类** 页面,内容区内再分 **上 / 中 / 下** 三块:
|
||||
|
||||
| 区域 | 内容 | 布局 |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| **上部 · 查询条** | **左侧:操作区**(常见:**新增**、**批量删除** 等,随业务增减);**右侧:条件查询区**(输入框、下拉等)。左右 **两端对齐**(`justify-content: space-between` 或 grid 两列)。 |
|
||||
| **中部 · 表格** | 数据表格,列随实体定义;**数据随查询条件变化**(前端驱动请求参数,或由 Zustand/React Query 绑定 filter state)。 |
|
||||
| **下部 · 分页** | 分页器;**每页条数** 可选 **10 / 20 / 50**(默认可先 20);**允许按业务页单独配置**(通过 props 或页面级常量覆盖默认值)。 |
|
||||
|
||||
**表格行内操作(约定)**
|
||||
|
||||
| 操作 | 说明 |
|
||||
| -------- | ------------------------------------------------------------- |
|
||||
| **修改** | 行级入口,跳转编辑页、侧滑/抽屉表单或行内编辑(按模块选型)。 |
|
||||
| **删除** | 行级删除。 |
|
||||
|
||||
**删除与批量删除(接口约定)**
|
||||
|
||||
- **单条删除**与**批量删除(工具栏)**共用 **同一后端批量删除能力**(例如 `POST .../batch-delete` 或 `DELETE` + body 为 `ids: []`);单条删除时传 **仅含一个 id 的列表**,避免维护两套删除路径与权限点。
|
||||
- 前端:表格多选 + 「批量删除」与行内「删除」最终都走上述接口;确认弹窗文案区分「删除所选 N 条」与「删除本条」即可。
|
||||
|
||||
非列表页(表单页、详情页)可只使用白色卡片容器 + 内边距,不强制三区块,但 **边距与圆角** 与上表保持一致。
|
||||
|
||||
### D. 内容区布局变体(除「纯表格式列表」外)
|
||||
|
||||
| 形态 | 适用场景 | 结构要点 |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **左树 + 右表 + 分页** | 数据依赖**树形上下文**的列表(如 **用户管理**:左侧 **部门/组织机构树**,右侧用户表 + 查询条 + 分页) | 左侧树与右侧表 **同一张灰底白卡片容器内**或左右分栏两卡片;**选中树节点**作为查询条件(如 `dept_id` / `org_id`),切换节点时刷新右侧表格与分页回到第 1 页。 |
|
||||
| **整页树(可带工具栏)** | **层级数据本身即主对象**(如 **菜单管理**、部分 **目录/分类**) | 主区为 **可编辑树**(拖拽排序、增删改节点等按后端能力);若需与列表混用,可树下方再挂从表(按产品)。 |
|
||||
|
||||
**线框示意(左树右表,如用户管理)**
|
||||
|
||||
```text
|
||||
灰底 12px
|
||||
+-----------------------------------------------------------------------+
|
||||
| +-- 白卡片 ----------------------------------------------------------------+ |
|
||||
| | +------------------+ +------------------------------------------------+ | |
|
||||
| | | 部门 / 组织树 | | [+新增] [批量删除] [条件…] [查询] [重置] | | |
|
||||
| | | ▾ 公司 | +------------------------------------------------+ | |
|
||||
| | | ▾ 研发部 | | 表头 | 操作(修改/删除) | … | | |
|
||||
| | | ▾ 市场部 | | 数据 | [改][删] | … | | |
|
||||
| | | … | +------------------------------------------------+ | |
|
||||
| | | (可选宽 240–280) | | 共 N 条 [10|20|50/页] < 1 2 3 > | | |
|
||||
| | +------------------+ +------------------------------------------------+ | |
|
||||
| +-------------------------------------------------------------------------+ |
|
||||
+-----------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### E. 组织机构与用户管理是否合一(计划约定)
|
||||
|
||||
- **常见做法(推荐默认)**:**组织机构(部门树)**作为 **用户管理** 的左侧上下文,与 **用户列表** 放在 **同一功能页 / 同一菜单入口**(左树右表);用户的新增/编辑表单里 **归属部门** 与树联动。这样避免「组织」与「用户」两处维护同一棵部门树时的割裂感。
|
||||
- **何时拆页**:若产品要求 **组织机构** 单独做 **编制、合并、禁用** 等重操作,且与用户列表 **强解耦**,可另开 **「部门管理」** 子菜单;数据上仍与用户的 `dept_id` 同源,前端避免重复实现两套树数据源(宜共用 hook / 同一 `dept` API)。
|
||||
- **结论**:计划层面 **默认采用「用户管理 = 部门树 + 用户表」一体化**;是否再单列「组织机构」顶级菜单由产品命名决定(可做成 **同路由别名** 或 **子 Tab:用户 | 部门**),实现上不强制两套壳。
|
||||
|
||||
**推荐实现(一种可落地方案)**
|
||||
|
||||
| 层次 | 做法 |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **路由** | **一个主路由**承载左树右表,例如 `/iam/users`(与后端菜单 `path` 对齐)。部门相关「重操作」优先做 **同页 Modal / 右侧抽屉 / 全屏子路由**(如 `/iam/users/dept`),避免先复制一套独立壳再维护两份树状态。 |
|
||||
| **部门树数据源** | 封装 **`useDeptTree`**(React Query:`queryKey: ['dept', 'tree', tenantId]`),全应用 **仅此一处拉树**;用户页、(若有的)部门管理页、用户表单里的部门选择器 **都复用同一 query**,树更新后 **`invalidateQueries(['dept', 'tree', …])` 一处失效即可**。 |
|
||||
| **选中部门** | 用 **`useState` + 可选 URL `?deptId=`**(`nuqs` 或 Next `useSearchParams`):刷新、分享链接可恢复上下文;切换节点时 **用户列表页码重置为 1**。 |
|
||||
| **用户列表** | **`useUserList({ deptId, page, pageSize, …filters })`**,`deptId` 来自选中节点(根节点可表示「全部」或 `undefined` 由后端约定);查询条与树筛选 **合并为同一请求参数**。 |
|
||||
| **行内 / 批量删** | 与上文约定一致,走 **同一 batch-delete**;表格 `rowSelection` 与批量按钮共用 `ids`。 |
|
||||
| **用户表单与树联动** | 新增/编辑用 **受控的部门选择**(下拉树、`TreeSelect` 或内嵌窄树);`dept_id` 初始值 = 当前左侧选中节点或行数据;若用户在表单里改部门,**不必**自动改左侧选中(避免抢焦点),保存成功后 **刷新列表** 即可。 |
|
||||
| **部门 CRUD(轻)** | 在树工具条放「新增子部门」「编辑」「禁用」→ **Modal**;成功后 **invalidate 部门树 + 用户列表**(若影响可见范围)。 |
|
||||
| **部门 CRUD(重)** | 若合并、批量迁移等交互很重,可 **另开菜单** 指向 **独立页面**,该页仍 import **同一套 `features/dept` 模块**(hooks + api),禁止复制 `DeptTree` 组件实现。 |
|
||||
|
||||
**小结**:用 **「单一路由 + 单一 dept tree query + 可选 searchParam 记录选中节点」** 最省事;重功能用 **子路由或抽屉** 消化,数据层仍 **一套 dept API、一套 React Query key 前缀**。
|
||||
|
||||
**计划采纳**:上述 **「推荐实现」表格 + 小结** 为本方案 **用户/部门一体化页的默认实现**;后续除非产品另有要求,按此执行。
|
||||
|
||||
**线框示意(列表页)**
|
||||
|
||||
```text
|
||||
灰色背景 (外层 12px 边距)
|
||||
+------------------------------------------------------------------+
|
||||
| +-- 白色圆角卡片 (padding 12px) --------------------------------+ |
|
||||
| | [+新增][批量删除] [条件A][条件B][查询][重置] | |
|
||||
| | ---------------------------------------------------------------- | |
|
||||
| | | 表头 | 表头 | ... | |
|
||||
| | | 数据 | 数据 | ... | |
|
||||
| | ---------------------------------------------------------------- | |
|
||||
| | 共 N 条 [10|20|50/页] < 1 2 3 > | |
|
||||
| +----------------------------------------------------------------+ |
|
||||
+------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
### 整页综合线框图(Header + 左导航 + Tabs + 内容区标准列表)
|
||||
|
||||
```text
|
||||
+======================================================================================+
|
||||
|| Header ||
|
||||
|| [Logo] Smart [租户…] [○ 用户 ▼] ← 展开含侧栏经典/图标、退出等 ||
|
||||
+===+==================================================================================+
|
||||
|| || Tabs 栏(右键:关闭 / 关左 / 关右 / 关全部;概览不可关) ||
|
||||
|| || +---+ +--------------------------------------------------------------------+ ||
|
||||
|| || | ◀ | | 概览 | 用户× | 角色× | 租户× | …overflow… | ▶ | ||
|
||||
|| || +---+ +--------------------------------------------------------------------+ ||
|
||||
|| || ^固定^ ^滚动钮常显^ ||
|
||||
|| || +-----------------------------------------------------------------------------+|
|
||||
|| || | 灰底 12px 边距(左/上/右;底同) | ||
|
||||
|| || | +-------------------------------------------------------------------------+ | ||
|
||||
|| || | | 白底圆角卡片 (内边距 12px) | | ||
|
||||
|| || | | [+新增] [批量删除] [条件…] [查询] [重置] ←上:左右对齐 | | ||
|
||||
|| || | |-------------------------------------------------------------------------| | ||
|
||||
|| || | | | 列1 | 列2 | 列3 | ... ←中:表格 | | ||
|
||||
|| || | | | 数据| 数据| 数据| | | ||
|
||||
|| || | |-------------------------------------------------------------------------| | ||
|
||||
|| || | | 共 N 条 每页 [10▼] [20] [50] < 1 2 3 > ←下:分页 | | ||
|
||||
|| || | +-------------------------------------------------------------------------+ | ||
|
||||
|| || +-----------------------------------------------------------------------------+|
|
||||
|| || ||
|
||||
||左|| ||
|
||||
||侧|| ||
|
||||
||导|| ||
|
||||
||航|| ||
|
||||
|| || ||
|
||||
||树|| ||
|
||||
||状|| ||
|
||||
|| || ||
|
||||
+===+==================================================================================+
|
||||
```
|
||||
|
||||
**实现提示**:Tabs 状态与路由 **可双向同步**(新开 tab 推 history 或仅内存,按团队选择);右键菜单项需 **禁用态**(例如当前 tab 左侧无 tab 时「关闭左侧」禁用)。
|
||||
|
||||
---
|
||||
|
||||
## 左导航:经典模式 vs 图标模式(约定含义)
|
||||
|
||||
业内常见两种叫法,与你描述的「经典 / 图标」一般对应如下(若你希望另一种交互,可再改一版文案):
|
||||
|
||||
| 模式 | 典型形态 | 体验要点 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
|
||||
| **经典模式** | 侧栏 **较宽**(约 220–260px),每项 **图标 + 文字** 并排展示;多级菜单可展开/折叠,**当前选中态**清晰 | 信息密度高,适合首次使用与菜单项较多的管理后台 |
|
||||
| **图标模式** | 侧栏 **收窄**(约 56–72px),**仅显示图标**;文字通过 **Tooltip** 或 **悬停浮层** 展示;子菜单可用 **Popover / 侧滑面板** 或点击展开窄条下的二级 | 主内容区更宽,适合熟练用户;类似 VS Code 活动栏、许多 SaaS 的「收起侧边栏」 |
|
||||
|
||||
**实现要点(计划内约定):**
|
||||
|
||||
- 使用 **Zustand**(如 `useShellStore`)保存 `sidebarMode: 'classic' | 'icon'`,并用 **`localStorage` 持久化**(键名如 `smart_sidebar_mode`),刷新后保持用户选择。
|
||||
- 布局用 **CSS 变量或 Tailwind** 控制侧栏宽度;`transition` 做宽度切换动画(可选)。
|
||||
- **无障碍**:图标模式下每个图标按钮必须带 **`aria-label`**,与 Tooltip 文案一致。
|
||||
- 与 **Radix**:`Tooltip` + `NavigationMenu` 或自研侧栏;避免纯 div 堆叠导致键盘无法操作。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph shell [AppShell]
|
||||
Top[顶栏]
|
||||
subgraph left [左导航]
|
||||
Classic[经典: 宽栏+图文]
|
||||
IconOnly[图标: 窄栏+Tooltip]
|
||||
end
|
||||
Main[内容区]
|
||||
end
|
||||
Top --> left
|
||||
left --> Main
|
||||
```
|
||||
|
||||
### 线框图(ASCII)
|
||||
|
||||
**经典模式(宽侧栏:图标 + 文字)**
|
||||
|
||||
```text
|
||||
+----------------------------------------------------------------------------------+
|
||||
| [Logo] Smart Admin [租户: 平台] [用户 ▼] [退出] ←「用户」下拉内含侧栏经典/图标 |
|
||||
+----------+-----------------------------------------------------------------------+
|
||||
| [i] 首页 | |
|
||||
| [i] 系统 | 主内容区(列表 / 表单 / 详情) |
|
||||
| 参数 | |
|
||||
| [i] IAM | +---------------------------------------------------------------+ |
|
||||
| 用户 | | 表格 / 卡片 / 步骤条 … | |
|
||||
| 角色 | +---------------------------------------------------------------+ |
|
||||
| ... | |
|
||||
| | |
|
||||
+----------+-----------------------------------------------------------------------+
|
||||
^约 220–260px^
|
||||
```
|
||||
|
||||
**图标模式(窄侧栏:仅图标,文字用 Tooltip)**
|
||||
|
||||
```text
|
||||
+----------------------------------------------------------------------------------+
|
||||
| [Logo] Smart Admin [租户] [用户 ▼] [退出] ←「用户」下拉内含侧栏经典/图标 |
|
||||
+--+-------------------------------------------------------------------------------+
|
||||
|[]| |
|
||||
|[]| 主内容区(更宽) |
|
||||
|[]| |
|
||||
|[]| +---------------------------------------------------------------+ |
|
||||
|[]| | | |
|
||||
|[]| +---------------------------------------------------------------+ |
|
||||
| | |
|
||||
+--+-------------------------------------------------------------------------------+
|
||||
^约 56–72px^
|
||||
悬停图标 → Tooltip「用户管理」;子菜单可 Popover 或右侧滑出
|
||||
```
|
||||
|
||||
**两种模式对比(同一壳,仅侧栏宽度与是否显示标签变化)**
|
||||
|
||||
```text
|
||||
经典 图标
|
||||
+--------------------+ +--+---------------+
|
||||
| [≡] 用户管理 | |👤| 用户列表… |
|
||||
| [≡] 角色管理 | ↔ |👥| |
|
||||
| [≡] … | |⚙ | (Tooltip) |
|
||||
+--------------------+ +--+---------------+
|
||||
```
|
||||
|
||||
### 图标模式下的二级、三级导航(常见交互)
|
||||
|
||||
窄栏里**无法像经典模式那样纵向展开整棵树**,多级菜单通常用下面几类方式之一(可混用,按菜单深度与数量选型):
|
||||
|
||||
| 方案 | 行为 | 适用 |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| **A. 右侧飞出级联面板** | 点击一级图标 → 在侧栏右侧弹出 **浮层 1** 列出二级;在某项上悬停或点击 → 再向右弹出 **浮层 2** 列三级(「手风琴式」级联,类似旧版 Windows 开始菜单多级) | 二、三级都多、需要快速扫视 |
|
||||
| **B. 单层面板 + 树/分组** | 点击一级图标 → 一个较宽的 **Popover / DropdownMenu** 内用 **可折叠分组** 或 **缩进树** 展示二、三级(可滚动) | 总项数中等、希望少移动鼠标 |
|
||||
| **C. 抽屉** | 点击带子女的图标 → 从左侧 **滑出抽屉**,内部为完整树(与经典侧栏同结构,只是按需出现) | 层级很深或名称很长 |
|
||||
| **D. 临时加宽** | 点击某父级后,侧栏在图标列旁 **临时展开一条「迷你文字列」** 仅显示该支路的二、三级 | 想兼顾窄栏与可读性 |
|
||||
|
||||
**线框示意(方案 A:向右级联)**
|
||||
|
||||
```text
|
||||
侧栏(窄) 浮层1(二级) 浮层2(三级)
|
||||
+--+ +-----------+ +-----------+
|
||||
|👤| 点击 → | 用户管理 | | 列表用户 |
|
||||
| | | 角色管理 | hover| 导入用户 |
|
||||
| | | 部门 ─────┼────→| 导出 |
|
||||
+--+ +-----------+ +-----------+
|
||||
```
|
||||
|
||||
**线框示意(方案 B:单 Popover 内多级)**
|
||||
|
||||
```text
|
||||
+--+
|
||||
|👤| 点击 → +-------------------------+
|
||||
+--+ | ▼ IAM |
|
||||
| 用户管理 |
|
||||
| 角色管理 |
|
||||
| ▼ 系统 |
|
||||
| 参数配置 ← 三级作为子项 |
|
||||
+-------------------------+
|
||||
```
|
||||
|
||||
**计划约定**:实现时 **同一套菜单树数据**(如 `menu/nav`)驱动经典树与图标模式;图标模式需为 **带 `children` 的节点** 绑定上述一种交互,并在设计稿中统一 **键盘操作**(Esc 关闭、方向键在级联中移动,可与 Radix DropdownMenu / NavigationMenu 能力对齐)。
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序建议
|
||||
|
||||
1. 初始化 Next 项目(TS + Prettier),配置 `NEXT_PUBLIC_API_ORIGIN`。
|
||||
2. **(Go)** 按 §3 改造 **`POST /api/v1/auth/login`**:密码通过后签发 **PKCE 绑定 `code`**,与 **`/oauth/token`** 复用;同步更新 **`docs/oauth-v2.md`**。
|
||||
3. 实现 **`apiClient` + `api/auth`**,跑通 **login → code → token**、logout 与一条 IAM 只读接口(如 `menu/nav`)。
|
||||
4. 搭建 **`app/(dashboard)/layout`**(或等价)实现 **AppShell + 左导航双模式** 与顶栏,菜单对接 `menu/nav`。
|
||||
5. 按模块把 **IAM、System** 全部方法补齐(与 [`http_register`](internal/iam/http_register.go) 路径一一对应),页面放入内容区。
|
||||
6. 接入 **OAuth2**(PKCE + callback + token 存储),与现有 Go 的 `client_id`/`redirect_uri` 配置一致(种子 `spa` 与 [`configs/local.yml`](configs/local.yml) 中 `frontend_login_url`)。
|
||||
7. Go 侧确认 **CORS + Cookie 策略** 与生产 HTTPS。
|
||||
8. (可选)后端增加 OpenAPI/Swagger 后,用 codegen 替换手写 DTO。
|
||||
|
||||
---
|
||||
|
||||
## 产品口径与待确认项
|
||||
|
||||
### 已定口径(按当前共识写入计划)
|
||||
|
||||
| 项 | 决定 |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **鉴权** | **仅 OAuth2 Code + PKCE + Bearer**;**`/api/v1/auth/login` 须改造为签发 PKCE 绑定 `code` 并与 `/oauth/token` 同一套逻辑**(当前 Go 仅为 Session,属待改偏差)。 |
|
||||
| **租户切换** | **必须关闭所有 Tabs**(页签状态清空),并 **重新拉** `menu/nav`、`dept/tree` 及当前内容区数据(与 `tenantId` / token 声明一致)。 |
|
||||
| **权限 · 第一期** | **仅做路由级守卫**(未登录或整页无权限 → 拦截/提示);**不做**表格内每颗按钮与 `perms` 的细粒度联动(后续迭代再加)。 |
|
||||
| **批量删除** | 与 Go 路由对齐;若某资源 **没有 batch 接口**,**实现侧先通知负责人,由负责人决定**补接口或临时方案,**不擅自拍板**。 |
|
||||
| **HTTP 与 body.code** | **`401`** = 认证失败(**含登录密码错误**),**前端以 HTTP 为准**;**`403`** = 无权限;**其余业务** **`HTTP 200`** + 体 `code`。**密码错误不单独用 `200`+业务码表达**。 |
|
||||
| **JSON 与 state** | 信封 **`{ code, msg, data }`**;**`auth/login` 可带 `state`**,成功 **`data` 回显**。 |
|
||||
| **401 / refresh** | **一般 API 的 401** → **先 refresh**,失败再弹窗;**`POST /auth/login` 的 401**(密码错等)→ **不 refresh**,直接按需弹窗(§3)。 |
|
||||
| **403** | **HTTP 403** → **toast**,留在当前页,**不弹登录框**。 |
|
||||
| **环境** | **生产同域**;**开发跨域** → Go **CORS 白名单** dev origin;种子 SPA 的 **`redirect_uri` 须含各环境回调**(见 §3 `client_id` 说明)。 |
|
||||
| **登出** | **`POST /auth/logout`** + 清前端 token;**多 Tab** 用 **BroadcastChannel / storage 事件** 同步登出。 |
|
||||
|
||||
### 术语说明(白话)
|
||||
|
||||
**部门树:根节点 =「全部」**
|
||||
|
||||
- 含义:左侧树**最顶层**(或「未选中具体部门」)表示 **不按部门过滤**,用户列表查 **「全部」**(具体 query 与后端约定)。
|
||||
- **是否含停用**:部门可能被 **停用**,需约定树里 **是否仍显示**这些节点(显示则可选中;不显示则界面更干净)。
|
||||
- **是否懒加载**:**懒加载** = 先只拉根,**展开再拉子节点**;**非懒加载** = **一次拉整棵树**。部门特别多时常用懒加载;树小可一次拉齐。
|
||||
|
||||
**Tabs 要不要写进 URL**
|
||||
|
||||
- **只存在内存**:开了多个页签,**一刷新浏览器页签全没**——可接受则实现简单。
|
||||
- **写进 URL**:刷新或 **分享链接** 能恢复多页签状态;实现更复杂,第一期**不强制**。
|
||||
|
||||
**部门「轻」vs「重」/ 独立页**
|
||||
|
||||
- **轻**:在用户管理页用 **Modal** 做 **加子部门、改名** 等简单维护。
|
||||
- **重**:**整页**做 **合并部门、批量迁移、复杂拖拽** 等,才需要 **单独「部门管理」菜单**。**MVP 建议先轻后重**,复用同一套 `dept` API。
|
||||
|
||||
### 仍待与后端核对
|
||||
|
||||
| 项 | 说明 |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| **批量删除契约** | 路径、`ids`、软删以 Gin 为准;**缺接口时通知负责人**。 |
|
||||
| **部门树 · 停用与懒加载** | 与后端行为对齐;未约定则联调时定一版。 |
|
||||
| **Tabs 是否进 URL** | 可第一期 **仅内存**,后续再加。 |
|
||||
|
||||
---
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- **「所有接口」** 以当前 Gin 注册为准;若后续新增路由,前端需同步增加 `api/*` 方法。
|
||||
- Bearer 中间件对无 token 请求**不 401**;**业务上需登录**由路由守卫与 **`apiClient`:先 refresh、失败再弹登录框** 协同处理。
|
||||
- 工作区若与 Go 仓库分离,请用 **同一文档** 维护 baseURL 与 `client_id`/redirect 列表。
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: OAuth2 PKCE 单体设计
|
||||
overview: 在单体部署中同时承担 AS 与 RS:逻辑分层 + **opaque access token**(随机串)存 PostgreSQL/Redis,RS 中间件按 token 查元数据完成校验;可选暴露 RFC 7662 introspection 供未来独立资源服务。前后端分离下后端不渲染 HTML,仅 JSON 登录与 302;OAuth 2.1 + 强制 PKCE,与现有 IAM 对齐。
|
||||
todos:
|
||||
- id: schema-oauth
|
||||
content: 设计并迁移 oauth_client、opaque access_token / refresh_token 元数据表(或 Redis)、authorization code 存储策略
|
||||
status: completed
|
||||
- id: session-login
|
||||
content: JSON 登录 API + HttpOnly Cookie 与 Redis 会话;未登录访问 /oauth/authorize 时 302 到配置的前端 URL(携带完整回跳 authorize 链接或 request_id),无后端 HTML
|
||||
status: completed
|
||||
- id: auth-code-pkce
|
||||
content: 实现 /oauth/authorize 与 /oauth/token(authorization_code + S256 PKCE + state)
|
||||
status: completed
|
||||
- id: opaque-token-mw
|
||||
content: 签发 opaque access token(高熵随机串,元数据存库)、Gin Bearer 中间件内联查库/Redis 解析用户与 scope;可选 POST /oauth/introspect(RFC 7662)
|
||||
status: completed
|
||||
- id: refresh-scope
|
||||
content: refresh_token 轮换、scope 与租户/权限映射、限流与审计
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# OAuth2 Authorization Code + PKCE(单体 AS+RS)设计
|
||||
|
||||
## 可行性结论
|
||||
|
||||
**可以这样做,而且是合理选择。** 单体同时作为 AS 与 RS 并不意味着违反 OAuth 模型:OAuth 描述的是**角色与端点**,不要求物理上分属不同进程。同一服务内:
|
||||
|
||||
- **授权服务器**:提供 `/oauth/authorize`、`/oauth/token`(以及可选的授权服务器元数据、**令牌自省**、撤销端点)。
|
||||
- **资源服务器**:现有 [`/api/v1/...`](internal/server/http.go) 等业务 API,通过 `Authorization: Bearer` 校验访问令牌。
|
||||
|
||||
部署形态为**单体**时,AS 签发 **opaque** 令牌并写入存储,RS 与 AS **同进程**:中间件直接 **查库/Redis** 解析 `sub`(用户 id)、租户、`scope`、`exp`,**无需** JWT 验签,也**不必**为 RS 单独 HTTP 调 introspection(除非你愿意统一走自省接口以保持代码路径单一)。若未来 RS 独立部署,再启用 **RFC 7662 introspection** 或共享 Redis/DB 读副本。
|
||||
|
||||
**选型说明(已定)**:**Access token 采用 opaque**——撤销可即时生效(删行或标 `revoked`)、不暴露声明给客户端解析;代价是每次 API 请求多一次存储读取(可用 Redis 缓存 + 短 TTL 与 DB 一致)。
|
||||
|
||||
与仓库内 [docs/oauth-v2.md](docs/oauth-v2.md)(OAuth 2.1 草案)对齐的要点:
|
||||
|
||||
- **Authorization Code** 为主流程;**PKCE**(`code_challenge` / `code_verifier`,推荐 S256)对各类客户端(含机密客户端)可防范授权码注入,草案要求除例外情况外**强制使用**(见文档中 4.1 与 7.5 节相关段落)。
|
||||
- **Redirect URI** 须严格字符串匹配(2.1 比 2.0 更严)。
|
||||
- **Implicit** 等 grant 在 2.1 中不再规定,**不要**实现隐式授权。
|
||||
- 生产环境 **HTTPS**、**短期 access token**、**scope/audience 约束**为最佳实践。
|
||||
|
||||
---
|
||||
|
||||
## 前后端分离约束(后端不返回 HTML)
|
||||
|
||||
本方案与「后端只提供 API」**兼容**:OAuth 规范里授权端点本来就是 **HTTP 重定向** 与浏览器导航,不要求授权服务器返回登录表单 HTML。
|
||||
|
||||
- **登录页、同意页**:由**前端 SPA** 渲染(独立域名或 `/login` 路由均可)。
|
||||
- **后端**提供:`POST /api/v1/.../login`(或 `auth/login`)等 **JSON** 接口校验 [`iam_user`](internal/iam/entity/user.go) 凭据,成功后通过 **`Set-Cookie`**(HttpOnly、Secure、`SameSite` 按跨站需求选择)建立会话,**响应体不写 HTML**。
|
||||
- **`GET /oauth/authorize`**:仅返回 **302**(已登录则带 `code` 重定向到 client;未登录则重定向到**配置的前端登录 URL**),或 **302 到错误 redirect_uri**(OAuth 标准错误响应)。禁止用 HTML 表单作为唯一交互方式。
|
||||
|
||||
**未登录时的典型串联**(浏览器内):
|
||||
|
||||
1. 用户浏览器访问 AS 的 `/oauth/authorize?...`(含 PKCE、`state`)。
|
||||
2. AS 发现无会话:**302** 到 `FRONTEND_LOGIN_URL`,并在 query 中携带「回到 authorize 所需信息」——常见两种做法(二选一或组合):
|
||||
- **回跳链接**:`next` / `return_to` = `urlencode(完整 authorize URL)`,前端登录成功后执行 `location.href = decode(next)` 再次命中 authorize(此时带 Cookie)。
|
||||
- **短期票据**:`login_challenge` / `request_id` 指向 Redis 中暂存的 authorize 查询参数,前端登录成功后调用后端「完成登录并继续授权」或仍用回跳 authorize URL(实现简单优先前者需额外端点)。
|
||||
3. 用户在 SPA 输入账号密码 → **`POST` JSON 登录** → Cookie 写入。
|
||||
4. 浏览器再次 **`GET` 同一 `/oauth/authorize?...`**(PKCE 参数必须与首次一致,故回跳必须带齐原始 query),AS 有会话则签发 `code` 并 **302** 到第三方/首方应用的 `redirect_uri`。
|
||||
|
||||
**跨域 Cookie**:若 API 与 SPA **不同站点**,需 `SameSite=None; Secure` 且 CORS 对登录与 authorize 请求配置 `credentials`,并在配置中固定 `Cookie` 的 `Domain`/`Path`;若同站不同路径则可用 `Lax`。此项在 [`pkg/config`](pkg/config) 中显式配置并写入计划实施阶段。
|
||||
|
||||
**同意(consent)**:若需要显式授权,同样 **302 到前端同意页**(带 `consent_challenge` 或回跳 authorize),用户提交后由前端再导航回 authorize 或调用完成端点——**仍无后端 HTML**。
|
||||
|
||||
---
|
||||
|
||||
## 目标架构(逻辑视图)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph client [OAuth客户端]
|
||||
Browser[浏览器/SPA]
|
||||
Native[原生应用]
|
||||
end
|
||||
subgraph monolith [单体后端]
|
||||
AuthZ["/oauth/authorize"]
|
||||
LoginAPI["POST JSON 登录"]
|
||||
Token["/oauth/token"]
|
||||
RS["/api/v1 资源API"]
|
||||
Intro["可选 /oauth/introspect"]
|
||||
end
|
||||
subgraph spa [前端应用]
|
||||
LoginUI[登录页UI]
|
||||
end
|
||||
subgraph store [持久化]
|
||||
PG[(PostgreSQL)]
|
||||
Redis[(Redis可选)]
|
||||
end
|
||||
Browser --> AuthZ
|
||||
AuthZ -->|未登录302| LoginUI
|
||||
LoginUI --> LoginAPI
|
||||
LoginAPI -->|Set-Cookie| Browser
|
||||
Browser --> AuthZ
|
||||
AuthZ -->|302 code| Browser
|
||||
Browser --> Token
|
||||
Token -->|opaque| Browser
|
||||
Browser --> RS
|
||||
RS -->|查 token 元数据| PG
|
||||
RS -.->|独立 RS 时| Intro
|
||||
Token --> PG
|
||||
LoginAPI --> Redis
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心组件设计
|
||||
|
||||
### 1. OAuth 客户端注册(Client Registry)
|
||||
|
||||
需要能校验 `client_id`、允许的 **redirect_uri 列表**、客户端类型(public / confidential)。
|
||||
|
||||
- **首方应用**(自有前端):可用**配置 + 种子数据**或表 `oauth_client`(`client_id`、哈希后的 `client_secret`(仅 confidential)、`redirect_uris[]`、`grant_types`、`token_endpoint_auth_method`)。
|
||||
- **第三方**:同样走表结构,由管理 API 维护(可后续迭代)。
|
||||
|
||||
OAuth 2.1 对 **public client** 不在 token 端点使用 secret;**confidential client** 使用 `client_secret`(或 `private_key_jwt`,可二期)。
|
||||
|
||||
### 2. 授权端点 `GET /oauth/authorize`
|
||||
|
||||
**查询参数**(RFC/2.1):`response_type=code`、`client_id`、`redirect_uri`、`scope`、`state`(**强烈建议必填**,防 CSRF)、`code_challenge`、`code_challenge_method=S256`。
|
||||
|
||||
**服务端行为**:
|
||||
|
||||
1. 校验 client、redirect_uri、PKCE 参数完整且 method 支持。
|
||||
2. 若用户**未登录**:**302** 到配置项 **`FRONTEND_LOGIN_URL`**(或等价路径),并携带**完整回跳**到本端点所需的参数(见上文「前后端分离约束」:`next`/`return_to` 或 `request_id`)。**不返回 HTML**。登录成功依赖后续 **`POST` JSON 登录** + **HttpOnly Cookie** + **Redis 会话**(与 [`internal/server/http.go`](internal/server/http.go) 中间件顺序一致)。
|
||||
3. 已登录用户:若需同意且未静默授权:**302** 到 **`FRONTEND_CONSENT_URL`**(同样仅重定向,无 HTML);首方可配置静默同意跳过。
|
||||
4. 生成 **authorization code**(一次性、短 TTL,如 60–120s),**绑定**:`client_id`、`redirect_uri`、`code_challenge`/`method`、**资源所有者 id**(即 [`iam_user`](internal/iam/entity/user.go) 主键)、**租户**(`tenant_id`)、请求的 `scope`。存 PostgreSQL 或 Redis(Redis 更适合极短 TTL)。
|
||||
5. 302 到 `redirect_uri?code=...&state=...`。
|
||||
|
||||
### 3. 令牌端点 `POST /oauth/token`
|
||||
|
||||
支持:
|
||||
|
||||
- `grant_type=authorization_code`:`code`、`redirect_uri`、`client_id`、**`code_verifier`**(PKCE);confidential 客户端另加 `client_secret` 或 HTTP Basic。
|
||||
- `grant_type=refresh_token`:**refresh token 轮换**(发新废旧,旧 token 入库标记撤销)推荐。
|
||||
|
||||
**校验**:
|
||||
|
||||
- 校验 code 未使用、未过期;`code_verifier` 与当时存的 `code_challenge` 按 S256 比对(与 [docs/oauth-v2.md](docs/oauth-v2.md) 一致)。
|
||||
- 颁发 **access_token**(**opaque**,高熵随机串)与 **refresh_token**(opaque);**存库字段建议**:`token` 或仅存 **哈希**(防 DB 泄露即泄露明文)、`user_id`、`tenant_id`、`client_id`、`scope`、`expires_at`、`revoked_at` 等。
|
||||
|
||||
错误响应遵循 OAuth 2.1 的 `application/json` 错误体约定。
|
||||
|
||||
### 4. Opaque Access Token 与资源服务器校验(默认方案)
|
||||
|
||||
**Access token** 为不可解析的随机串(opaque),**不**使用 JWT 自包含声明。
|
||||
|
||||
**存储与撤销**:表(或 Redis)保存令牌元数据;撤销时删除或标记 `revoked`,**立即生效**。
|
||||
|
||||
**RS 中间件**(挂在 [`apiGroup`](internal/server/http.go) 上需保护的子树):
|
||||
|
||||
- 从 `Authorization: Bearer` 取出 token,**查存储**(优先 Redis 缓存命中,miss 回源 PG)得到 `user_id`、`tenant_id`、`scope`、`exp`。
|
||||
- 校验未过期、未撤销;按需校验 `scope`。
|
||||
- 将 `user_id`、`tenant_id`、`scope` 写入 `gin.Context`,替代当前仅靠 [`X-User-ID` / `X-Tenant-ID`](internal/iam/handler/helpers.go) 的占位方式;对内网遗留 header 仅过渡且需防伪造。
|
||||
|
||||
**可选 `POST /oauth/introspect`**([RFC 7662](https://www.rfc-editor.org/rfc/rfc7662)):与上述查表语义一致,供**未来独立 RS** 或统一审计;单体同进程内可直接复用同一套存储访问逻辑,不必强制每次 API 都走 HTTP 自省。
|
||||
|
||||
**与 JWT 对比**:本方案不暴露 `/.well-known/jwks.json`(除非日后为 **id_token** 引入 JWT);若将来要支持纯离线验签再另议。
|
||||
|
||||
### 5. Scope 与现有 IAM 的映射
|
||||
|
||||
- 最小集:`openid`(若上 OIDC)、`profile`、`email`(按需,OIDC 层再扩展)。
|
||||
- API 资源:`api` 或细分 `user.read`、`admin` 等,与 [`iam_menu.perms`](migrations/postgres/001_iam.sql) / 角色体系映射:opaque 行内可存 **scope 字符串**;细粒度权限可在中间件加载后按 `user_id`+`tenant_id` **再查**角色/菜单(与「快照进 token」二选一,避免存储行过大)。
|
||||
|
||||
### 6. 安全与运维要点
|
||||
|
||||
- **HTTPS** 强制(开发可用本地证书或反向代理)。
|
||||
- **Redirect URI** 精确匹配;禁止 open redirect。
|
||||
- **Authorization code** 单次使用;建议**绑定 PKCE**(已实现则满足 2.1 核心要求)。
|
||||
- **Refresh token**:哈希存储、轮换、可撤销。
|
||||
- **限流**:`/oauth/token`、登录接口按 IP/client 限流。
|
||||
- **审计**:登录、发 token、撤销记日志。
|
||||
|
||||
---
|
||||
|
||||
## 与现有代码库的衔接点
|
||||
|
||||
| 区域 | 作用 |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`internal/server/http.go`](internal/server/http.go) | 注册 `/oauth/*`、可选 `/.well-known/oauth-authorization-server`、`/oauth/introspect`,并为 `/api/v1` 挂 Bearer 中间件 |
|
||||
| [`internal/iam`](internal/iam) | 复用用户校验(密码可用 [`pkg/utils/codec`](pkg/utils/codec/bcypt.go))、租户与用户查询 |
|
||||
| [`migrations/postgres`](migrations/postgres) | `oauth_client`、`oauth_access_token`(或统一 `oauth_token`)、`oauth_refresh_token`、authorization code 表或 Redis |
|
||||
| [`pkg/config`](pkg/config) | issuer URL、access/refresh TTL、`FRONTEND_LOGIN_URL` / `FRONTEND_CONSENT_URL`、跨域 Cookie 与 CORS |
|
||||
|
||||
依赖:OAuth 协议可自研或选用 **Fosite** / **go-oauth2**(需核对 opaque + 2.1 PKCE);**JWT 库仅在** 日后 OIDC `id_token` 时再引入。
|
||||
|
||||
---
|
||||
|
||||
## 建议代码落点(包结构)
|
||||
|
||||
与现有 [`internal/iam`](internal/iam)(租户/用户 CRUD)**并列**,新增独立边界 **`internal/auth`**:作为**各类认证方式的统一挂载点**(OAuth2、日后 SAML、LDAP、API Key 等),避免与 IAM 领域模型混包。**首期 OAuth2 Code + PKCE** 放在 **`internal/auth/oauth2/`** 子树(Go 子包 `oauth2`),与「账号密码会话」等可并列拆分,互不污染。
|
||||
|
||||
| 路径 | 内容 |
|
||||
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **`internal/auth/oauth2/handler`** | `authorize`、`token`、`introspect`、可选 `revocation` |
|
||||
| **`internal/auth/oauth2/service`** | 授权码、PKCE、opaque 签发/校验、refresh 轮换 |
|
||||
| **`internal/auth/oauth2/repository`** | `oauth_client`、token、authorization code 持久化 |
|
||||
| **`internal/auth/oauth2/entity`** | OAuth 表对应模型(关联 `iam_user`,用户主数据仍在 IAM) |
|
||||
| **`internal/auth/session/`**(可选子包) | JSON 登录、Cookie/Redis 会话(供 authorize 识别用户;命名避免与 `net/http` 概念混淆时可叫 `websession`) |
|
||||
| **`internal/auth/middleware`** 或 **`internal/server`** | **Bearer 查库中间件**(协议无关:只要是 opaque 就查存储);或放 `internal/server/auth.go` |
|
||||
| **`internal/auth/http_register.go`** | 聚合注册:实现 [`HttpRoutes`](internal/server/http.go),内部委托 `oauth2` 等子路由(与 [`internal/iam/http_register.go`](internal/iam/http_register.go) 同级注入) |
|
||||
| **`internal/auth/wire_provider.go`** | `ProviderSet`,在 [`cmd/server/wire.go`](cmd/server/wire.go) 注入 |
|
||||
|
||||
**命名说明**:顶层用 **`auth`** 而非 **`oauth`**,便于扩展;**HTTP 路径**仍可保持标准 **`/oauth/authorize`**、**`/oauth/token`**(协议规定,与包名无关)。
|
||||
|
||||
**JSON 登录**:建议 **`internal/auth/session`**(或 `handler/login.go` 于 `auth` 根下),与 **`internal/auth/oauth2`** 并列;**密码校验**复用 [`internal/iam/service`](internal/iam/service) + [`pkg/utils/codec`](pkg/utils/codec/bcypt.go)。
|
||||
|
||||
**配置**:[`pkg/config`](pkg/config) 增加 `Auth` 段(内含 `OAuth2`、`Session`、`CORS` 等子段)。
|
||||
|
||||
**迁移**:[`migrations/postgres`](migrations/postgres) 新文件如 `005_oauth.sql`(编号按仓库顺延)。
|
||||
|
||||
**入口组装**:[`internal/server/http.go`](internal/server/http.go) 的 `NewHttpRouteRegistrars` 增加 **`authRoutes`**;全局 Bearer 中间件在 `NewHttpEngine` 或路由组上按需挂载。
|
||||
|
||||
**纯工具**:PKCE SHA256、高熵随机串可放 **`pkg/security/token`**(或 `pkg/authutil`),避免 `internal/auth` 根包臃肿。
|
||||
|
||||
---
|
||||
|
||||
## 可选扩展(非首期必选)
|
||||
|
||||
- **OpenID Connect**:在 OAuth2 之上增加 `openid` scope、`/oauth/userinfo`;`id_token` 一般为 **JWT**(与 opaque **access_token** 并存不冲突)。
|
||||
- **RFC 7662**:独立资源服务时 RS 调 introspection;单体已用查表则可选暴露端点以保持对外契约一致。
|
||||
- **RP-Initiated Logout**:前后端分离时的统一登出。
|
||||
|
||||
---
|
||||
|
||||
## 实施阶段建议
|
||||
|
||||
1. **数据模型 + 配置**:客户端表、**opaque token 表/索引**(按 token 哈希查找)、TTL、**前端回跳 URL**、迁移脚本。
|
||||
2. **JSON 登录 + 会话**:`POST` 登录接口、HttpOnly Cookie + Redis,供 `authorize` 识别用户;与前端约定 `next`/`return_to` 回跳完整 authorize URL。
|
||||
3. **authorize + token + PKCE**:浏览器串联「authorize → 302 前端登录 → 再 authorize」;端到端打通 Code 换 **opaque** Token。
|
||||
4. **Bearer 中间件(查库/缓存)**:保护 `/api/v1`,上下文注入 `user_id`/`tenant_id`;可选 `introspect` 端点。
|
||||
5. **Refresh 轮换与撤销**、scope 与 IAM 权限对齐、集成测试(含跨域 Cookie 场景)。
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Reference https://github.com/github/gitignore/blob/master/Go.gitignore
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# OS General
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# project
|
||||
*.cert
|
||||
*.key
|
||||
*.log
|
||||
bin/
|
||||
|
||||
# Develop tools
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Configs
|
||||
configs/local.yml
|
||||
wire_gen.go
|
||||
__debug_bin.*
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"giter.top/smart/pkg/config"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
func init() {
|
||||
flag.StringVar(&configPath, "conf", "configs/local.yml", "config file path")
|
||||
}
|
||||
func main() {
|
||||
flag.Parse()
|
||||
// load config
|
||||
config, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("load config failed: %v", err)
|
||||
}
|
||||
// initialize server
|
||||
servers, err := InitializeServer(config)
|
||||
if err != nil || len(servers) == 0 {
|
||||
panic(err)
|
||||
}
|
||||
// 启动
|
||||
for _, srv := range servers {
|
||||
s := srv // 避免闭包问题,若需要
|
||||
go func() {
|
||||
if err := s.Run(); err != nil {
|
||||
log.Printf("server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 阻塞直到收到退出信号
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
// 再依次 Stop / Shutdown
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
// The build tag makes sure the stub is not built in the final build.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/auth"
|
||||
"giter.top/smart/internal/data"
|
||||
"giter.top/smart/internal/iam"
|
||||
"giter.top/smart/internal/server"
|
||||
"giter.top/smart/internal/system"
|
||||
"giter.top/smart/pkg/config"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
func InitializeServer(config *config.Config) ([]server.Server, error) {
|
||||
panic(wire.Build(
|
||||
server.ProviderSet,
|
||||
data.ProviderSet,
|
||||
auth.ProviderSet,
|
||||
system.ProviderSet,
|
||||
iam.ProviderSet,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
server:
|
||||
http:
|
||||
addr: "0.0.0.0:8000"
|
||||
timeout: 10s
|
||||
grpc:
|
||||
addr: "0.0.0.0:9000"
|
||||
timeout: 10s
|
||||
|
||||
data:
|
||||
database:
|
||||
driver: postgres # mysql, postgres, sqlite
|
||||
dsn: postgres://root:123456@127.0.0.1:5432/smart?sslmode=disable
|
||||
redis:
|
||||
# standalone | sentinel | cluster
|
||||
mode: standalone
|
||||
addrs:
|
||||
- 127.0.0.1:6379
|
||||
password: machine03
|
||||
db: 3
|
||||
pool_size: 100
|
||||
idle_timeout: 10s
|
||||
max_retries: 3
|
||||
retry_delay: 1s
|
||||
retry_max_delay: 10s
|
||||
# 哨兵示例(mode: sentinel,addrs 为 Sentinel 地址列表):
|
||||
# mode: sentinel
|
||||
# master_name: mymaster
|
||||
# addrs:
|
||||
# - 127.0.0.1:26379
|
||||
# - 127.0.0.1:26380
|
||||
# password: ""
|
||||
# db: 0
|
||||
# 集群示例(mode: cluster,addrs 为若干节点种子地址):
|
||||
# mode: cluster
|
||||
# addrs:
|
||||
# - 127.0.0.1:7000
|
||||
# - 127.0.0.1:7001
|
||||
# - 127.0.0.1:7002
|
||||
# password: ""
|
||||
@@ -0,0 +1,66 @@
|
||||
# 认证 HTTP 约定(Smart Go)
|
||||
|
||||
与 OAuth2 授权码 + PKCE 对齐,见仓库内 PKCE 校验实现:`internal/auth/oauth2`。
|
||||
|
||||
## 统一 JSON 信封(`/api/v1/auth/*`)
|
||||
|
||||
| HTTP | 说明 |
|
||||
|------|------|
|
||||
| `401` | 认证失败(如用户名或密码错误) |
|
||||
| `403` | 已认证但无权限(如用户已禁用) |
|
||||
| `200` | 其余:业务成功或 **OAuth 客户端/PKCE 元数据** 类错误(看 `code`) |
|
||||
| `5xx` | 服务端异常 |
|
||||
|
||||
响应体:
|
||||
|
||||
```json
|
||||
{ "code": 200, "msg": "操作成功", "data": { } }
|
||||
```
|
||||
|
||||
- 业务成功时 `code` 为 `200`。
|
||||
- 凭据错误以 **HTTP 401** 为准,可不依赖 `body.code` 做分支。
|
||||
|
||||
## `POST /api/v1/auth/login`
|
||||
|
||||
在校验 **用户名 + 密码** 通过后,签发与 `GET /oauth/authorize` **同一存储**的 **authorization_code**(绑定 PKCE),并 **Set-Cookie** 会话(便于浏览器再走 `/oauth/authorize`)。
|
||||
|
||||
### 请求体(JSON)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `user_name` | 是 | 登录名 |
|
||||
| `password` | 是 | 密码 |
|
||||
| `tenant_id` | 否 | 缺省为平台租户 |
|
||||
| `client_id` | 是 | 已注册的 OAuth 客户端 |
|
||||
| `redirect_uri` | 是 | 须在该客户端允许列表内 |
|
||||
| `code_challenge` | 是 | PKCE S256 |
|
||||
| `code_challenge_method` | 是 | 须为 `S256`(大小写不敏感) |
|
||||
| `state` | 否 | 成功时在 `data.state` 回显 |
|
||||
| `scope` | 否 | 缺省 `openid` |
|
||||
|
||||
### 成功 `HTTP 200`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"authorization_code": "<plain_code>",
|
||||
"state": "<若请求携带>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端用本地保存的 `code_verifier` 请求:
|
||||
|
||||
`POST /oauth/token`,`grant_type=authorization_code`,参数与标准授权码换 token 一致(`code`、`redirect_uri`、`client_id`、`code_verifier`)。
|
||||
|
||||
### 失败示例
|
||||
|
||||
- 用户名或密码错误:**HTTP 401**,`msg` 提示凭据错误。
|
||||
- 用户禁用:**HTTP 403**。
|
||||
- `client_id` / `redirect_uri` / PKCE 不合法:**HTTP 200**,`code`≠`200`,`msg` 说明原因。
|
||||
|
||||
## `POST /api/v1/auth/logout`
|
||||
|
||||
清除会话 Cookie;响应为信封 `code: 200`。
|
||||
@@ -0,0 +1,13 @@
|
||||
# CORS / HTTPS 清单(开发 → 生产)
|
||||
|
||||
## 开发(当前)
|
||||
|
||||
- Go 已加 [`internal/server/cors.go`](../internal/server/cors.go):仅 **Origin 为 `http://localhost:*` / `http://127.0.0.1:*` / `http://[::1]:*`** 时反射 `Access-Control-Allow-Origin` 并允许 **Credentials**。
|
||||
- 前端 `web/.env.local` 设置 `NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000`(与 Go 监听一致)。
|
||||
- OAuth `redirect_uri` 须与 Go /oauth 客户端种子登记一致(如 `http://localhost:3000/oauth/callback`)。
|
||||
|
||||
## 生产
|
||||
|
||||
- **同源**:Next 与 Go 同站点 / 反代为同一 Origin 时,浏览器 **简单请求无 CORS**;请关闭或收紧对公网暴露的 CORS。
|
||||
- **HTTPS**:Cookie `Secure`、`SameSite` 与 OAuth 重定向须用 **HTTPS**;参阅 `configs` 里 Session 配置。
|
||||
- 多环境 `redirect_uri`:在 OAuth 客户端表中登记各环境回调 URL。
|
||||
+2413
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
module giter.top/smart
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // 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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,189 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"giter.top/smart/internal/auth/oauth2"
|
||||
"giter.top/smart/internal/auth/session"
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
iamrepo "giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/config"
|
||||
"giter.top/smart/pkg/utils/codec"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LoginHandler JSON 登录:校验密码后签发 OAuth2 授权码(PKCE),并可选下发会话 Cookie(与 /oauth/authorize 兼容)。
|
||||
type LoginHandler struct {
|
||||
cfg *config.Config
|
||||
users iamrepo.UserRepository
|
||||
sess *session.Store
|
||||
oauth *oauth2.Service
|
||||
}
|
||||
|
||||
// NewLoginHandler 构造。
|
||||
func NewLoginHandler(cfg *config.Config, users iamrepo.UserRepository, sess *session.Store, oauth *oauth2.Service) *LoginHandler {
|
||||
return &LoginHandler{cfg: cfg, users: users, sess: sess, oauth: oauth}
|
||||
}
|
||||
|
||||
type loginBody struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserName string `json:"user_name"`
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeChallengeMethod string `json:"code_challenge_method"`
|
||||
State string `json:"state"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type apiEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Login POST /api/v1/auth/login
|
||||
func (h *LoginHandler) Login(c *gin.Context) {
|
||||
var req loginBody
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "请求参数无效: " + err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
if req.UserName == "" || req.Password == "" {
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 user_name 或 password", Data: nil})
|
||||
return
|
||||
}
|
||||
if req.ClientID == "" || req.RedirectURI == "" || req.CodeChallenge == "" {
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 client_id、redirect_uri 或 code_challenge", Data: nil})
|
||||
return
|
||||
}
|
||||
if req.CodeChallengeMethod == "" {
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 code_challenge_method", Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
tid := req.TenantID
|
||||
if tid == "" {
|
||||
tid = entity.PlatformTenantID
|
||||
}
|
||||
|
||||
u, err := h.users.GetByUserName(c.Request.Context(), tid, req.UserName)
|
||||
if err != nil {
|
||||
slog.Warn("auth_login_failed", "reason", "user_not_found", "tenant_id", tid, "user_name", req.UserName, "client_ip", c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, apiEnvelope{Code: 401, Msg: "用户名或密码错误", Data: nil})
|
||||
return
|
||||
}
|
||||
if err := codec.VerifyPassword(req.Password, u.PasswordHash); err != nil {
|
||||
slog.Warn("auth_login_failed", "reason", "bad_password", "tenant_id", tid, "user_name", req.UserName, "client_ip", c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, apiEnvelope{Code: 401, Msg: "用户名或密码错误", Data: nil})
|
||||
return
|
||||
}
|
||||
if u.Status != 1 {
|
||||
slog.Warn("auth_login_failed", "reason", "user_disabled", "tenant_id", tid, "user_id", u.ID, "client_ip", c.ClientIP())
|
||||
c.JSON(http.StatusForbidden, apiEnvelope{Code: 403, Msg: "用户已禁用", Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
codePlain, err := h.oauth.IssueAuthorizationCodeAfterPasswordAuth(
|
||||
c.Request.Context(),
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
u.ID,
|
||||
u.TenantID,
|
||||
req.Scope,
|
||||
req.CodeChallenge,
|
||||
req.CodeChallengeMethod,
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, oauth2.ErrInvalidClient):
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "无效的 client_id", Data: nil})
|
||||
return
|
||||
case errors.Is(err, oauth2.ErrInvalidRedirectURI):
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "redirect_uri 与客户端登记不一致", Data: nil})
|
||||
return
|
||||
case errors.Is(err, oauth2.ErrPKCERequired):
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "code_challenge 或 code_challenge_method 无效(需 S256)", Data: nil})
|
||||
return
|
||||
default:
|
||||
slog.Error("auth_login_issue_code", "err", err)
|
||||
c.JSON(http.StatusInternalServerError, apiEnvelope{Code: 500, Msg: "服务器错误", Data: nil})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sid, err := h.sess.Create(c.Request.Context(), u.ID, u.TenantID)
|
||||
if err != nil {
|
||||
slog.Error("auth_login_session", "err", err)
|
||||
c.JSON(http.StatusInternalServerError, apiEnvelope{Code: 500, Msg: "会话创建失败", Data: nil})
|
||||
return
|
||||
}
|
||||
h.setSessionCookie(c, sid)
|
||||
|
||||
data := gin.H{
|
||||
"authorization_code": codePlain,
|
||||
}
|
||||
if req.State != "" {
|
||||
data["state"] = req.State
|
||||
}
|
||||
|
||||
slog.Info("auth_login_ok", "tenant_id", u.TenantID, "user_id", u.ID, "user_name", req.UserName, "client_ip", c.ClientIP())
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 200, Msg: "操作成功", Data: data})
|
||||
}
|
||||
|
||||
// Logout POST /api/v1/auth/logout
|
||||
func (h *LoginHandler) Logout(c *gin.Context) {
|
||||
sid, err := c.Cookie(h.cfg.Auth.Session.CookieName)
|
||||
if err == nil && sid != "" {
|
||||
_ = h.sess.Delete(c.Request.Context(), sid)
|
||||
}
|
||||
h.clearSessionCookie(c)
|
||||
c.JSON(http.StatusOK, apiEnvelope{Code: 200, Msg: "操作成功", Data: nil})
|
||||
}
|
||||
|
||||
func (h *LoginHandler) setSessionCookie(c *gin.Context, sid string) {
|
||||
same := sameSite(h.cfg.Auth.Session.SameSite)
|
||||
ttl := h.cfg.Auth.Session.TTL
|
||||
if ttl == 0 {
|
||||
ttl = 168 * time.Hour
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: h.cfg.Auth.Session.CookieName,
|
||||
Value: sid,
|
||||
Path: "/",
|
||||
Domain: h.cfg.Auth.Session.CookieDomain,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
Secure: h.cfg.Auth.Session.CookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: same,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LoginHandler) clearSessionCookie(c *gin.Context) {
|
||||
same := sameSite(h.cfg.Auth.Session.SameSite)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: h.cfg.Auth.Session.CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: h.cfg.Auth.Session.CookieDomain,
|
||||
MaxAge: -1,
|
||||
Secure: h.cfg.Auth.Session.CookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: same,
|
||||
})
|
||||
}
|
||||
|
||||
func sameSite(s string) http.SameSite {
|
||||
switch s {
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
default:
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/auth/handler"
|
||||
"giter.top/smart/internal/auth/oauth2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthRoutes 认证相关 HTTP(OAuth2、登录)。
|
||||
type AuthRoutes struct {
|
||||
bearer gin.HandlerFunc
|
||||
loginRL gin.HandlerFunc
|
||||
tokenRL gin.HandlerFunc
|
||||
oauthH *oauth2.Handler
|
||||
loginH *handler.LoginHandler
|
||||
}
|
||||
|
||||
// NewAuthRoutes 构造(loginRL/tokenRL 使用 Wire 专用类型,见 wire_provider.go)。
|
||||
func NewAuthRoutes(bearer gin.HandlerFunc, loginRL LoginRateLimitWire, tokenRL TokenRateLimitWire, oauthH *oauth2.Handler, loginH *handler.LoginHandler) *AuthRoutes {
|
||||
return &AuthRoutes{
|
||||
bearer: bearer,
|
||||
loginRL: gin.HandlerFunc(loginRL),
|
||||
tokenRL: gin.HandlerFunc(tokenRL),
|
||||
oauthH: oauthH,
|
||||
loginH: loginH,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 实现 server.HttpRoutes:OAuth 在根路径,/api/v1 挂 Bearer 与登录。
|
||||
func (r *AuthRoutes) Register(engine *gin.Engine, apiGroup *gin.RouterGroup) {
|
||||
apiGroup.Use(r.bearer)
|
||||
apiGroup.POST("/auth/login", r.loginRL, r.loginH.Login)
|
||||
apiGroup.POST("/auth/logout", r.loginH.Logout)
|
||||
|
||||
engine.GET("/oauth/authorize", r.oauthH.Authorize)
|
||||
engine.POST("/oauth/token", r.tokenRL, r.oauthH.Token)
|
||||
engine.POST("/oauth/introspect", r.tokenRL, r.oauthH.Introspect)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"giter.top/smart/internal/auth/oauth2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Context keys for auth principal
|
||||
const (
|
||||
CtxUserID = "auth_user_id"
|
||||
CtxTenantID = "auth_tenant_id"
|
||||
CtxScope = "auth_scope"
|
||||
)
|
||||
|
||||
// NewBearer 解析 opaque Bearer access_token,写入上下文;无 Bearer 或无效时继续放行(兼容未迁移接口)。
|
||||
func NewBearer(store *oauth2.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(h, prefix))
|
||||
if raw == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
p, err := store.LookupAccessToken(c.Request.Context(), raw)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, p.UserID)
|
||||
c.Set(CtxTenantID, p.TenantID)
|
||||
c.Set(CtxScope, p.Scope)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// PerIPMinute 按客户端 IP 的固定窗口速率(每分钟 perMinute 次,burst 取 perMinute 与 64 的较小值)。
|
||||
// 进程内 map 可能随 IP 数增长,多实例部署请在网关侧限流。
|
||||
func PerIPMinute(enabled bool, perMinute int) gin.HandlerFunc {
|
||||
if !enabled || perMinute <= 0 {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
burst := perMinute
|
||||
if burst > 64 {
|
||||
burst = 64
|
||||
}
|
||||
if burst < 5 {
|
||||
burst = 5
|
||||
}
|
||||
lim := rate.Limit(float64(perMinute) / 60.0)
|
||||
var mu sync.Mutex
|
||||
limiters := make(map[string]*rate.Limiter)
|
||||
return func(c *gin.Context) {
|
||||
ip := clientIP(c)
|
||||
mu.Lock()
|
||||
limiter, ok := limiters[ip]
|
||||
if !ok {
|
||||
limiter = rate.NewLimiter(lim, burst)
|
||||
limiters[ip] = limiter
|
||||
}
|
||||
mu.Unlock()
|
||||
if !limiter.Allow() {
|
||||
c.AbortWithStatusJSON(429, gin.H{"error": "rate_limit_exceeded"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
||||
if err != nil {
|
||||
return c.Request.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package oauth2
|
||||
|
||||
import "errors"
|
||||
|
||||
// JSON 登录签发授权码时与 Authorize 对齐校验。
|
||||
var (
|
||||
ErrInvalidClient = errors.New("oauth2: invalid client_id")
|
||||
ErrInvalidRedirectURI = errors.New("oauth2: invalid redirect_uri")
|
||||
ErrPKCERequired = errors.New("oauth2: invalid code_challenge or code_challenge_method")
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package oauth2
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Handler 绑定 Gin 与 Service。
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler 构造。
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
// Authorize GET /oauth/authorize
|
||||
func (h *Handler) Authorize(c *gin.Context) {
|
||||
h.svc.Authorize(c)
|
||||
}
|
||||
|
||||
// Token POST /oauth/token
|
||||
func (h *Handler) Token(c *gin.Context) {
|
||||
h.svc.Token(c)
|
||||
}
|
||||
|
||||
// Introspect POST /oauth/introspect
|
||||
func (h *Handler) Introspect(c *gin.Context) {
|
||||
h.svc.Introspect(c)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package oauth2
|
||||
|
||||
import "time"
|
||||
|
||||
// OAuthClient oauth_client
|
||||
type OAuthClient struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
ClientID string `gorm:"size:64;not null;uniqueIndex"`
|
||||
ClientSecretHash *string `gorm:"size:255"`
|
||||
RedirectURIsJSON string `gorm:"column:redirect_uris;type:text;not null"`
|
||||
IsPublic bool `gorm:"not null;default:true"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (OAuthClient) TableName() string { return "oauth_client" }
|
||||
|
||||
// OAuthAuthorizationCode oauth_authorization_code
|
||||
type OAuthAuthorizationCode struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
CodeHash string `gorm:"size:64;not null;uniqueIndex"`
|
||||
ClientID string `gorm:"size:64;not null"`
|
||||
RedirectURI string `gorm:"type:text;not null"`
|
||||
UserID string `gorm:"size:36;not null"`
|
||||
TenantID string `gorm:"size:36;not null"`
|
||||
Scope string `gorm:"type:text;not null"`
|
||||
CodeChallenge string `gorm:"size:128;not null"`
|
||||
CodeChallengeMethod string `gorm:"size:16;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
Used bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (OAuthAuthorizationCode) TableName() string { return "oauth_authorization_code" }
|
||||
|
||||
// OAuthAccessToken oauth_access_token
|
||||
type OAuthAccessToken struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
TokenHash string `gorm:"size:64;not null;uniqueIndex"`
|
||||
ClientID string `gorm:"size:64;not null"`
|
||||
UserID string `gorm:"size:36;not null"`
|
||||
TenantID string `gorm:"size:36;not null"`
|
||||
Scope string `gorm:"type:text;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
RevokedAt *time.Time `gorm:""`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (OAuthAccessToken) TableName() string { return "oauth_access_token" }
|
||||
|
||||
// OAuthRefreshToken oauth_refresh_token
|
||||
type OAuthRefreshToken struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
TokenHash string `gorm:"size:64;not null;uniqueIndex"`
|
||||
AccessTokenID string `gorm:"size:36;not null;index"`
|
||||
ClientID string `gorm:"size:64;not null"`
|
||||
UserID string `gorm:"size:36;not null"`
|
||||
TenantID string `gorm:"size:36;not null"`
|
||||
Scope string `gorm:"type:text;not null"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
RevokedAt *time.Time `gorm:""`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (OAuthRefreshToken) TableName() string { return "oauth_refresh_token" }
|
||||
@@ -0,0 +1,23 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyPKCES256 校验 code_verifier 是否与 code_challenge(S256)一致。
|
||||
func VerifyPKCES256(codeVerifier, codeChallenge string) bool {
|
||||
if codeVerifier == "" || codeChallenge == "" {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(codeVerifier))
|
||||
expected := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(codeChallenge)) == 1
|
||||
}
|
||||
|
||||
// NormalizeCodeChallengeMethod 返回小写方法名;仅支持 S256(OAuth 2.1 推荐)。
|
||||
func NormalizeCodeChallengeMethod(m string) string {
|
||||
return strings.TrimSpace(strings.ToLower(m))
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"giter.top/smart/internal/auth/session"
|
||||
"giter.top/smart/pkg/config"
|
||||
"giter.top/smart/pkg/security"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Service OAuth2 授权码 + PKCE + opaque token。
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
store *Store
|
||||
sess *session.Store
|
||||
}
|
||||
|
||||
// NewService 构造。
|
||||
func NewService(cfg *config.Config, store *Store, sess *session.Store) *Service {
|
||||
return &Service{cfg: cfg, store: store, sess: sess}
|
||||
}
|
||||
|
||||
func (s *Service) durations() (authCode, access, refresh time.Duration) {
|
||||
authCode = s.cfg.Auth.OAuth2.AuthCodeTTL
|
||||
if authCode == 0 {
|
||||
authCode = 120 * time.Second
|
||||
}
|
||||
access = s.cfg.Auth.OAuth2.AccessTokenTTL
|
||||
if access == 0 {
|
||||
access = 15 * time.Minute
|
||||
}
|
||||
refresh = s.cfg.Auth.OAuth2.RefreshTokenTTL
|
||||
if refresh == 0 {
|
||||
refresh = 720 * time.Hour
|
||||
}
|
||||
return authCode, access, refresh
|
||||
}
|
||||
|
||||
// IssueAuthorizationCodeAfterPasswordAuth 在已通过用户名密码校验的上下文中签发 PKCE 绑定授权码(与 Authorize 中 CreateAuthorizationCode 一致)。
|
||||
func (s *Service) IssueAuthorizationCodeAfterPasswordAuth(ctx context.Context, clientID, redirectURI, userID, tenantID, scope, codeChallenge, challengeMethod string) (codePlain string, err error) {
|
||||
if scope == "" {
|
||||
scope = "openid"
|
||||
}
|
||||
method := NormalizeCodeChallengeMethod(challengeMethod)
|
||||
if codeChallenge == "" || method != "s256" {
|
||||
return "", ErrPKCERequired
|
||||
}
|
||||
cli, err := s.store.GetClientByClientID(ctx, clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", ErrInvalidClient
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
uris, err := ParseRedirectURIs(cli.RedirectURIsJSON)
|
||||
if err != nil || !RedirectURIMatch(uris, redirectURI) {
|
||||
return "", ErrInvalidRedirectURI
|
||||
}
|
||||
codePlain, err = security.RandomURLSafe(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
codeTTL, _, _ := s.durations()
|
||||
exp := time.Now().Add(codeTTL)
|
||||
if err := s.store.CreateAuthorizationCode(ctx, codePlain, clientID, redirectURI, userID, tenantID, scope, codeChallenge, "S256", exp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codePlain, nil
|
||||
}
|
||||
|
||||
func (s *Service) publicAuthorizeURL(c *gin.Context) string {
|
||||
base := strings.TrimRight(s.cfg.Auth.PublicBaseURL, "/")
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if xf := c.GetHeader("X-Forwarded-Proto"); xf == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + c.Request.Host
|
||||
}
|
||||
return base + "/oauth/authorize?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
// Authorize GET /oauth/authorize
|
||||
func (s *Service) Authorize(c *gin.Context) {
|
||||
q := c.Request.URL.Query()
|
||||
if q.Get("response_type") != "code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_response_type"})
|
||||
return
|
||||
}
|
||||
clientID := q.Get("client_id")
|
||||
redirectURI := q.Get("redirect_uri")
|
||||
state := q.Get("state")
|
||||
scope := q.Get("scope")
|
||||
if scope == "" {
|
||||
scope = "openid"
|
||||
}
|
||||
challenge := q.Get("code_challenge")
|
||||
method := NormalizeCodeChallengeMethod(q.Get("code_challenge_method"))
|
||||
if challenge == "" || method != "s256" {
|
||||
s.redirectOAuthError(c, redirectURI, state, "invalid_request", "code_challenge and code_challenge_method=S256 required")
|
||||
return
|
||||
}
|
||||
|
||||
cli, err := s.store.GetClientByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_client"})
|
||||
return
|
||||
}
|
||||
uris, err := ParseRedirectURIs(cli.RedirectURIsJSON)
|
||||
if err != nil || !RedirectURIMatch(uris, redirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
sid, err := c.Cookie(s.cfg.Auth.Session.CookieName)
|
||||
if err != nil || sid == "" {
|
||||
login := strings.TrimRight(s.cfg.Auth.OAuth2.FrontendLoginURL, "?")
|
||||
ret := s.publicAuthorizeURL(c)
|
||||
u, _ := url.Parse(login)
|
||||
q2 := u.Query()
|
||||
q2.Set("return_to", ret)
|
||||
u.RawQuery = q2.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
return
|
||||
}
|
||||
userID, tenantID, err := s.sess.Get(c.Request.Context(), sid)
|
||||
if err != nil {
|
||||
login := strings.TrimRight(s.cfg.Auth.OAuth2.FrontendLoginURL, "?")
|
||||
ret := s.publicAuthorizeURL(c)
|
||||
u, _ := url.Parse(login)
|
||||
q2 := u.Query()
|
||||
q2.Set("return_to", ret)
|
||||
u.RawQuery = q2.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
return
|
||||
}
|
||||
|
||||
codePlain, err := security.RandomURLSafe(32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
codeTTL, _, _ := s.durations()
|
||||
exp := time.Now().Add(codeTTL)
|
||||
if err := s.store.CreateAuthorizationCode(c.Request.Context(), codePlain, clientID, redirectURI, userID, tenantID, scope, challenge, "S256", exp); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
redir, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_redirect_uri"})
|
||||
return
|
||||
}
|
||||
rq := redir.Query()
|
||||
rq.Set("code", codePlain)
|
||||
if state != "" {
|
||||
rq.Set("state", state)
|
||||
}
|
||||
redir.RawQuery = rq.Encode()
|
||||
c.Redirect(http.StatusFound, redir.String())
|
||||
}
|
||||
|
||||
func (s *Service) redirectOAuthError(c *gin.Context, redirectURI, state, errCode, desc string) {
|
||||
if redirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errCode, "error_description": desc})
|
||||
return
|
||||
}
|
||||
u, e := url.Parse(redirectURI)
|
||||
if e != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errCode})
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", errCode)
|
||||
q.Set("error_description", desc)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
}
|
||||
|
||||
// Token POST /oauth/token
|
||||
func (s *Service) Token(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
gt := c.PostForm("grant_type")
|
||||
switch gt {
|
||||
case "authorization_code":
|
||||
s.tokenAuthorizationCode(c)
|
||||
case "refresh_token":
|
||||
s.tokenRefresh(c)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_grant_type"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) tokenAuthorizationCode(c *gin.Context) {
|
||||
code := c.PostForm("code")
|
||||
redirectURI := c.PostForm("redirect_uri")
|
||||
clientID := c.PostForm("client_id")
|
||||
verifier := c.PostForm("code_verifier")
|
||||
if code == "" || redirectURI == "" || clientID == "" || verifier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
row, err := s.store.ConsumeAuthorizationCode(c.Request.Context(), code, clientID, redirectURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant"})
|
||||
return
|
||||
}
|
||||
if !VerifyPKCES256(verifier, row.CodeChallenge) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant", "error_description": "pkce verification failed"})
|
||||
return
|
||||
}
|
||||
_, accessTTL, refreshTTL := s.durations()
|
||||
accessPlain, err := security.RandomURLSafe(32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
refreshPlain, err := security.RandomURLSafe(48)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
if err := s.store.IssueAccessAndRefresh(c.Request.Context(), accessPlain, refreshPlain, clientID, row.UserID, row.TenantID, row.Scope, accessTTL, refreshTTL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
slog.Info("oauth2_token_issued", "grant_type", "authorization_code", "client_id", clientID, "user_id", row.UserID, "tenant_id", row.TenantID, "client_ip", c.ClientIP())
|
||||
s.jsonAccessToken(c, accessPlain, refreshPlain, accessTTL)
|
||||
}
|
||||
|
||||
func (s *Service) tokenRefresh(c *gin.Context) {
|
||||
refresh := c.PostForm("refresh_token")
|
||||
clientID := c.PostForm("client_id")
|
||||
if refresh == "" || clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
_, accessTTL, refreshTTL := s.durations()
|
||||
newAccess, err := security.RandomURLSafe(32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
newRefresh, err := security.RandomURLSafe(48)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
|
||||
return
|
||||
}
|
||||
if err := s.store.RotateByRefreshToken(c.Request.Context(), clientID, refresh, newAccess, newRefresh, accessTTL, refreshTTL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant"})
|
||||
return
|
||||
}
|
||||
slog.Info("oauth2_token_issued", "grant_type", "refresh_token", "client_id", clientID, "client_ip", c.ClientIP())
|
||||
s.jsonAccessToken(c, newAccess, newRefresh, accessTTL)
|
||||
}
|
||||
|
||||
func (s *Service) jsonAccessToken(c *gin.Context, access, refresh string, accessTTL time.Duration) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(accessTTL.Seconds()),
|
||||
"refresh_token": refresh,
|
||||
})
|
||||
}
|
||||
|
||||
// Introspect POST /oauth/introspect(RFC 7662),与 opaque 查表语义一致。
|
||||
func (s *Service) Introspect(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
tok := c.PostForm("token")
|
||||
hint := strings.TrimSpace(c.PostForm("token_type_hint"))
|
||||
if tok == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
tryRefreshFirst := hint == "refresh_token"
|
||||
if tryRefreshFirst {
|
||||
if row, err := s.store.LookupRefreshTokenRow(ctx, tok); err == nil {
|
||||
slog.Info("oauth2_introspect", "active", true, "token_type", "refresh_token", "client_id", row.ClientID, "sub", row.UserID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": true,
|
||||
"scope": row.Scope,
|
||||
"client_id": row.ClientID,
|
||||
"token_type": "refresh_token",
|
||||
"sub": row.UserID,
|
||||
"exp": row.ExpiresAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
if row, err := s.store.LookupAccessTokenRow(ctx, tok); err == nil {
|
||||
slog.Info("oauth2_introspect", "active", true, "token_type", "access_token", "client_id", row.ClientID, "sub", row.UserID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": true,
|
||||
"scope": row.Scope,
|
||||
"client_id": row.ClientID,
|
||||
"token_type": "access_token",
|
||||
"sub": row.UserID,
|
||||
"exp": row.ExpiresAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if row, err := s.store.LookupRefreshTokenRow(ctx, tok); err == nil {
|
||||
slog.Info("oauth2_introspect", "active", true, "token_type", "refresh_token", "client_id", row.ClientID, "sub", row.UserID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": true,
|
||||
"scope": row.Scope,
|
||||
"client_id": row.ClientID,
|
||||
"token_type": "refresh_token",
|
||||
"sub": row.UserID,
|
||||
"exp": row.ExpiresAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ErrNotFound 未找到记录。
|
||||
var ErrNotFound = errors.New("oauth2: not found")
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Store OAuth 持久化。
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewStore 创建 Store。
|
||||
func NewStore(db *gorm.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// GetClientByClientID 按 client_id 查客户端。
|
||||
func (st *Store) GetClientByClientID(ctx context.Context, clientID string) (*OAuthClient, error) {
|
||||
var row OAuthClient
|
||||
err := st.db.WithContext(ctx).Where("client_id = ?", clientID).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ParseRedirectURIs 解析 redirect_uris JSON 数组。
|
||||
func ParseRedirectURIs(raw string) ([]string, error) {
|
||||
var uris []string
|
||||
if raw == "" {
|
||||
return nil, errors.New("empty redirect_uris")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &uris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uris, nil
|
||||
}
|
||||
|
||||
// RedirectURIMatch OAuth 2.1 精确匹配。
|
||||
func RedirectURIMatch(allowed []string, u string) bool {
|
||||
for _, x := range allowed {
|
||||
if x == u {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateAuthorizationCode 写入授权码(code 明文仅返回给调用方,库存哈希)。
|
||||
func (st *Store) CreateAuthorizationCode(ctx context.Context, codePlain string, clientID, redirectURI, userID, tenantID, scope, challenge, method string, expiresAt time.Time) error {
|
||||
row := OAuthAuthorizationCode{
|
||||
ID: id.New(),
|
||||
CodeHash: hashToken(codePlain),
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Scope: scope,
|
||||
CodeChallenge: challenge,
|
||||
CodeChallengeMethod: method,
|
||||
ExpiresAt: expiresAt,
|
||||
Used: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
return st.db.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
// ConsumeAuthorizationCode 校验并一次性消费授权码,返回行数据供发 token。
|
||||
func (st *Store) ConsumeAuthorizationCode(ctx context.Context, codePlain, clientID, redirectURI string) (*OAuthAuthorizationCode, error) {
|
||||
h := hashToken(codePlain)
|
||||
var out *OAuthAuthorizationCode
|
||||
err := st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row OAuthAuthorizationCode
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("code_hash = ?", h).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if row.Used {
|
||||
return ErrNotFound
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if row.ClientID != clientID || row.RedirectURI != redirectURI {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Model(&OAuthAuthorizationCode{}).Where("id = ?", row.ID).Update("used", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out = &row
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TokenPrincipal opaque access token 解析结果。
|
||||
type TokenPrincipal struct {
|
||||
UserID string
|
||||
TenantID string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// LookupAccessToken 按明文 access token 查有效记录。
|
||||
func (st *Store) LookupAccessToken(ctx context.Context, raw string) (*TokenPrincipal, error) {
|
||||
h := hashToken(raw)
|
||||
var row OAuthAccessToken
|
||||
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &TokenPrincipal{UserID: row.UserID, TenantID: row.TenantID, Scope: row.Scope}, nil
|
||||
}
|
||||
|
||||
// LookupAccessTokenRow 按明文查 access token 行(自省用)。
|
||||
func (st *Store) LookupAccessTokenRow(ctx context.Context, raw string) (*OAuthAccessToken, error) {
|
||||
h := hashToken(raw)
|
||||
var row OAuthAccessToken
|
||||
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LookupRefreshTokenRow 按明文查 refresh token 行(自省用)。
|
||||
func (st *Store) LookupRefreshTokenRow(ctx context.Context, raw string) (*OAuthRefreshToken, error) {
|
||||
h := hashToken(raw)
|
||||
var row OAuthRefreshToken
|
||||
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// IssueAccessAndRefresh 写入 access + refresh(opaque 明文仅调用方返回给客户端)。
|
||||
func (st *Store) IssueAccessAndRefresh(ctx context.Context, accessPlain, refreshPlain, clientID, userID, tenantID, scope string, accessTTL, refreshTTL time.Duration) error {
|
||||
now := time.Now()
|
||||
accessID := id.New()
|
||||
refreshID := id.New()
|
||||
at := OAuthAccessToken{
|
||||
ID: accessID,
|
||||
TokenHash: hashToken(accessPlain),
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Scope: scope,
|
||||
ExpiresAt: now.Add(accessTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
rt := OAuthRefreshToken{
|
||||
ID: refreshID,
|
||||
TokenHash: hashToken(refreshPlain),
|
||||
AccessTokenID: accessID,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Scope: scope,
|
||||
ExpiresAt: now.Add(refreshTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
return st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&at).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&rt).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RotateByRefreshToken 使用 refresh 换发新 access+refresh,旧令牌作废;client_id 须与注册一致。
|
||||
func (st *Store) RotateByRefreshToken(ctx context.Context, clientID, refreshPlain, newAccessPlain, newRefreshPlain string, accessTTL, refreshTTL time.Duration) error {
|
||||
h := hashToken(refreshPlain)
|
||||
return st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row OAuthRefreshToken
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if row.ClientID != clientID {
|
||||
return ErrNotFound
|
||||
}
|
||||
if time.Now().After(row.ExpiresAt) {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
if err := tx.Model(&OAuthRefreshToken{}).Where("id = ?", row.ID).Update("revoked_at", now).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&OAuthAccessToken{}).Where("id = ?", row.AccessTokenID).Update("revoked_at", now).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newAID := id.New()
|
||||
newRID := id.New()
|
||||
at := OAuthAccessToken{
|
||||
ID: newAID,
|
||||
TokenHash: hashToken(newAccessPlain),
|
||||
ClientID: row.ClientID,
|
||||
UserID: row.UserID,
|
||||
TenantID: row.TenantID,
|
||||
Scope: row.Scope,
|
||||
ExpiresAt: now.Add(accessTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
rt := OAuthRefreshToken{
|
||||
ID: newRID,
|
||||
TokenHash: hashToken(newRefreshPlain),
|
||||
AccessTokenID: newAID,
|
||||
ClientID: row.ClientID,
|
||||
UserID: row.UserID,
|
||||
TenantID: row.TenantID,
|
||||
Scope: row.Scope,
|
||||
ExpiresAt: now.Add(refreshTTL),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&at).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&rt).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Split 将空格分隔的 scope 拆成列表。
|
||||
func Split(scope string) []string {
|
||||
if strings.TrimSpace(scope) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Fields(scope)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Contains 判断 scope 字符串是否包含指定权限标记。
|
||||
func Contains(scope, want string) bool {
|
||||
if want == "" {
|
||||
return true
|
||||
}
|
||||
for _, s := range Split(scope) {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAPIAccess 约定含 `api` 或 `api.*` 前缀即表示可访问业务 API(可与 IAM 菜单权限组合使用)。
|
||||
func HasAPIAccess(scope string) bool {
|
||||
for _, s := range Split(scope) {
|
||||
if s == "api" || strings.HasPrefix(s, "api.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"giter.top/smart/pkg/config"
|
||||
"giter.top/smart/pkg/security"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ErrInvalidSession 会话不存在或已过期。
|
||||
var ErrInvalidSession = errors.New("session: invalid or expired")
|
||||
|
||||
const redisKeyPrefix = "auth:sess:"
|
||||
|
||||
type payload struct {
|
||||
UserID string `json:"user_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// Store Redis 会话(供 OAuth authorize 与登出)。
|
||||
type Store struct {
|
||||
rdb redis.UniversalClient
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewStore 创建会话存储。
|
||||
func NewStore(rdb redis.UniversalClient, cfg *config.Config) *Store {
|
||||
return &Store{rdb: rdb, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Store) ttl() time.Duration {
|
||||
t := s.cfg.Auth.Session.TTL
|
||||
if t == 0 {
|
||||
return 168 * time.Hour
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Create 创建会话并返回 session id(写入 Cookie 用)。
|
||||
func (s *Store) Create(ctx context.Context, userID, tenantID string) (sid string, err error) {
|
||||
sid, err = security.RandomURLSafe(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := json.Marshal(payload{UserID: userID, TenantID: tenantID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sid, s.rdb.Set(ctx, redisKeyPrefix+sid, b, s.ttl()).Err()
|
||||
}
|
||||
|
||||
// Get 解析会话。
|
||||
func (s *Store) Get(ctx context.Context, sid string) (userID, tenantID string, err error) {
|
||||
b, err := s.rdb.Get(ctx, redisKeyPrefix+sid).Bytes()
|
||||
if err == redis.Nil {
|
||||
return "", "", ErrInvalidSession
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return "", "", ErrInvalidSession
|
||||
}
|
||||
return p.UserID, p.TenantID, nil
|
||||
}
|
||||
|
||||
// Delete 登出时删除。
|
||||
func (s *Store) Delete(ctx context.Context, sid string) error {
|
||||
return s.rdb.Del(ctx, redisKeyPrefix+sid).Err()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/auth/handler"
|
||||
"giter.top/smart/internal/auth/middleware"
|
||||
"giter.top/smart/internal/auth/oauth2"
|
||||
"giter.top/smart/internal/auth/session"
|
||||
"giter.top/smart/pkg/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// ProviderSet Wire 注入。
|
||||
var ProviderSet = wire.NewSet(
|
||||
session.NewStore,
|
||||
oauth2.NewStore,
|
||||
oauth2.NewService,
|
||||
oauth2.NewHandler,
|
||||
handler.NewLoginHandler,
|
||||
ProvideBearer,
|
||||
ProvideLoginRLimitWire,
|
||||
ProvideTokenRLimitWire,
|
||||
NewAuthRoutes,
|
||||
)
|
||||
|
||||
// ProvideBearer 提供 Gin 中间件。
|
||||
func ProvideBearer(store *oauth2.Store) gin.HandlerFunc {
|
||||
return middleware.NewBearer(store)
|
||||
}
|
||||
|
||||
// LoginRateLimitWire、TokenRateLimitWire 用于 Wire 区分多个 gin.HandlerFunc 形参。
|
||||
type LoginRateLimitWire gin.HandlerFunc
|
||||
type TokenRateLimitWire gin.HandlerFunc
|
||||
|
||||
// ProvideLoginRLimitWire 登录接口限流。
|
||||
func ProvideLoginRLimitWire(cfg *config.Config) LoginRateLimitWire {
|
||||
return LoginRateLimitWire(middleware.PerIPMinute(cfg.Auth.RateLimit.Enabled, cfg.Auth.RateLimit.LoginPerMinute))
|
||||
}
|
||||
|
||||
// ProvideTokenRLimitWire 令牌与自省端点限流。
|
||||
func ProvideTokenRLimitWire(cfg *config.Config) TokenRateLimitWire {
|
||||
return TokenRateLimitWire(middleware.PerIPMinute(cfg.Auth.RateLimit.Enabled, cfg.Auth.RateLimit.TokenPerMinute))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"giter.top/smart/pkg/cache"
|
||||
"giter.top/smart/pkg/db"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(db.NewDB, cache.NewRedis)
|
||||
@@ -0,0 +1,24 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Dept 部门 iam_dept(根部门 parent_id 为空字符串)
|
||||
type Dept struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
TenantID string `json:"tenant_id" gorm:"size:36;not null;index:idx_dept_tenant"`
|
||||
ParentID string `json:"parent_id" gorm:"size:36;default:'';index:idx_dept_parent"`
|
||||
DeptName string `json:"dept_name" gorm:"size:128;not null"`
|
||||
DeptPath string `json:"dept_path" gorm:"type:text"`
|
||||
LeaderID *string `json:"leader_id" gorm:"size:36"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
Status int16 `json:"status" gorm:"default:1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (Dept) TableName() string { return "iam_dept" }
|
||||
@@ -0,0 +1,32 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PublicOverviewPerms 动态导航中「概览页」类公开权限标识(PRD:所有用户默认可见,需在菜单中配置同名 perms)
|
||||
const PublicOverviewPerms = "public:overview"
|
||||
|
||||
// Menu 菜单 iam_menu(全局,不按租户分表;根节点 parent_id 为空字符串)
|
||||
type Menu struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
ParentID string `json:"parent_id" gorm:"size:36;default:'';index:idx_menu_parent"`
|
||||
MenuName string `json:"menu_name" gorm:"size:128;not null"`
|
||||
MenuType int16 `json:"menu_type" gorm:"not null"` // 1目录 2菜单 3按钮
|
||||
Perms string `json:"perms" gorm:"size:128;uniqueIndex"`
|
||||
Path string `json:"path" gorm:"size:255"`
|
||||
Component string `json:"component" gorm:"size:255"`
|
||||
Icon string `json:"icon" gorm:"size:64"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
IsVisible bool `json:"is_visible" gorm:"default:true"`
|
||||
IsBuiltin bool `json:"is_builtin" gorm:"default:false"`
|
||||
ExternalLink string `json:"external_link" gorm:"size:512"`
|
||||
Status int16 `json:"status" gorm:"default:1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (Menu) TableName() string { return "iam_menu" }
|
||||
@@ -0,0 +1,43 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 数据范围并集优先级(数值越大权限越大)
|
||||
const (
|
||||
DataScopeSelf int16 = 1
|
||||
DataScopeDept int16 = 2
|
||||
DataScopeDeptTree int16 = 3
|
||||
DataScopeAll int16 = 4
|
||||
)
|
||||
|
||||
// Role 角色 iam_role
|
||||
type Role struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
TenantID string `json:"tenant_id" gorm:"size:36;not null;index:idx_role_tenant"`
|
||||
RoleCode string `json:"role_code" gorm:"size:64;not null"`
|
||||
RoleName string `json:"role_name" gorm:"size:128;not null"`
|
||||
DataScope int16 `json:"data_scope" gorm:"default:4"` // 1本人 2本部门 3本部门及子部门 4全部
|
||||
Description string `json:"description" gorm:"size:512"`
|
||||
IsBuiltin bool `json:"is_builtin" gorm:"default:false"`
|
||||
Status int16 `json:"status" gorm:"default:1"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (Role) TableName() string { return "iam_role" }
|
||||
|
||||
|
||||
// RoleMenu 角色菜单 iam_role_menu
|
||||
type RoleMenu struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
RoleID string `json:"role_id" gorm:"size:36;not null;uniqueIndex:uk_role_menu"`
|
||||
MenuID string `json:"menu_id" gorm:"size:36;not null;uniqueIndex:uk_role_menu"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (RoleMenu) TableName() string { return "iam_role_menu" }
|
||||
@@ -0,0 +1,25 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PlatformTenantID 平台租户主键(与初始化数据一致;菜单维护等仅平台租户可操作)
|
||||
const PlatformTenantID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// Tenant 租户 iam_tenant
|
||||
type Tenant struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
TenantCode string `json:"tenant_code" gorm:"size:64;uniqueIndex;not null"`
|
||||
TenantName string `json:"tenant_name" gorm:"size:128;not null"`
|
||||
AdminUserID *string `json:"admin_user_id" gorm:"size:36"`
|
||||
Status int16 `json:"status" gorm:"default:1"` // 1 正常 0 冻结 -1 删除(逻辑)
|
||||
ExpireTime *time.Time `json:"expire_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (Tenant) TableName() string { return "iam_tenant" }
|
||||
@@ -0,0 +1,53 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User 用户 iam_user
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
TenantID string `json:"tenant_id" gorm:"size:36;not null;index:idx_user_tenant"`
|
||||
DeptID *string `json:"dept_id" gorm:"size:36;index:idx_user_dept"`
|
||||
UserName string `json:"user_name" gorm:"size:64;not null"`
|
||||
RealName string `json:"real_name" gorm:"size:64"`
|
||||
PasswordHash string `json:"-" gorm:"size:255;not null"`
|
||||
Phone string `json:"phone" gorm:"size:20"`
|
||||
Email string `json:"email" gorm:"size:128"`
|
||||
Avatar string `json:"avatar" gorm:"size:512"`
|
||||
Gender int16 `json:"gender" gorm:"default:0"`
|
||||
Status int16 `json:"status" gorm:"default:1"`
|
||||
LoginAttempts int `json:"login_attempts" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"locked_until"`
|
||||
LastLoginAt *time.Time `json:"last_login_at"`
|
||||
LastLoginIP string `json:"last_login_ip" gorm:"size:45"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "iam_user" }
|
||||
|
||||
|
||||
// UserDept 用户部门关联 iam_user_dept
|
||||
type UserDept struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
UserID string `json:"user_id" gorm:"size:36;not null;uniqueIndex:uk_user_dept"`
|
||||
DeptID string `json:"dept_id" gorm:"size:36;not null;uniqueIndex:uk_user_dept"`
|
||||
IsPrimary bool `json:"is_primary" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserDept) TableName() string { return "iam_user_dept" }
|
||||
|
||||
// UserRole 用户角色 iam_user_role
|
||||
type UserRole struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36);not null"`
|
||||
UserID string `json:"user_id" gorm:"size:36;not null;uniqueIndex:uk_user_role"`
|
||||
RoleID string `json:"role_id" gorm:"size:36;not null;uniqueIndex:uk_user_role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (UserRole) TableName() string { return "iam_user_role" }
|
||||
@@ -0,0 +1,95 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DeptHandler struct {
|
||||
svc service.DeptService
|
||||
}
|
||||
|
||||
func NewDeptHandler(svc service.DeptService) *DeptHandler {
|
||||
return &DeptHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *DeptHandler) Tree(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
keyword := c.Query("keyword")
|
||||
var leaderID *string
|
||||
if s := c.Query("leader_id"); s != "" {
|
||||
leaderID = &s
|
||||
}
|
||||
tree, err := h.svc.Tree(c.Request.Context(), tid, keyword, leaderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
func (h *DeptHandler) Create(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var req service.CreateDeptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
d, err := h.svc.Create(c.Request.Context(), tid, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, d)
|
||||
}
|
||||
|
||||
func (h *DeptHandler) Update(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req service.UpdateDeptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
d, err := h.svc.Update(c.Request.Context(), tid, id, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (h *DeptHandler) Delete(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), tid, ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *DeptHandler) Get(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
d, err := h.svc.Get(c.Request.Context(), tid, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, d)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
authmw "giter.top/smart/internal/auth/middleware"
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func atoiDef(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// headerTenantID 当前租户:优先 OAuth2 Bearer 解析结果,其次 X-Tenant-ID,缺省平台租户。
|
||||
func headerTenantID(c *gin.Context) string {
|
||||
if v, ok := c.Get(authmw.CtxTenantID); ok {
|
||||
if s, ok2 := v.(string); ok2 && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
s := c.GetHeader("X-Tenant-ID")
|
||||
if s == "" {
|
||||
return entity.PlatformTenantID
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// headerUserID 当前用户:优先 OAuth2 opaque access_token 对应用户,其次 X-User-ID。
|
||||
func headerUserID(c *gin.Context) string {
|
||||
if v, ok := c.Get(authmw.CtxUserID); ok {
|
||||
if s, ok2 := v.(string); ok2 && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return c.GetHeader("X-User-ID")
|
||||
}
|
||||
|
||||
// headerGrantorUserID 请求头 X-Grantor-User-ID(授权人,用于防越权校验)
|
||||
func headerGrantorUserID(c *gin.Context) *string {
|
||||
s := c.GetHeader("X-Grantor-User-ID")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MenuHandler struct {
|
||||
svc service.MenuService
|
||||
}
|
||||
|
||||
func NewMenuHandler(svc service.MenuService) *MenuHandler {
|
||||
return &MenuHandler{svc: svc}
|
||||
}
|
||||
|
||||
func isPlatformAdmin(c *gin.Context) bool {
|
||||
return headerTenantID(c) == entity.PlatformTenantID
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Create(c *gin.Context) {
|
||||
var req service.CreateMenuRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Create(c.Request.Context(), &req, isPlatformAdmin(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅平台管理员可维护菜单"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Update(c *gin.Context) {
|
||||
mid := c.Param("id")
|
||||
if mid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req service.UpdateMenuRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Update(c.Request.Context(), mid, &req, isPlatformAdmin(c))
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅平台管理员可维护菜单"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Delete(c *gin.Context) {
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), ids, isPlatformAdmin(c)); err != nil {
|
||||
if errors.Is(err, repository.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅平台管理员可维护菜单"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Get(c *gin.Context) {
|
||||
mid := c.Param("id")
|
||||
if mid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Get(c.Request.Context(), mid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Tree(c *gin.Context) {
|
||||
var mt *int16
|
||||
if s := c.Query("menu_type"); s != "" {
|
||||
v64, err := strconv.ParseInt(s, 10, 16)
|
||||
if err == nil {
|
||||
v := int16(v64)
|
||||
mt = &v
|
||||
}
|
||||
}
|
||||
tree, err := h.svc.Tree(c.Request.Context(), mt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Nav(c *gin.Context) {
|
||||
uid := headerUserID(c)
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 X-User-ID"})
|
||||
return
|
||||
}
|
||||
tree, err := h.svc.NavForUser(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tree)
|
||||
}
|
||||
|
||||
func (h *MenuHandler) Perms(c *gin.Context) {
|
||||
uid := headerUserID(c)
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 X-User-ID"})
|
||||
return
|
||||
}
|
||||
perms, err := h.svc.PermsForUser(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"perms": perms})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RoleHandler struct {
|
||||
svc service.RoleService
|
||||
}
|
||||
|
||||
func NewRoleHandler(svc service.RoleService) *RoleHandler {
|
||||
return &RoleHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *RoleHandler) Create(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var req service.CreateRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
role, err := h.svc.Create(c.Request.Context(), tid, &req, headerGrantorUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, role)
|
||||
}
|
||||
|
||||
func (h *RoleHandler) Update(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
rid := c.Param("id")
|
||||
if rid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req service.UpdateRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
r, err := h.svc.Update(c.Request.Context(), tid, rid, &req, headerGrantorUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
func (h *RoleHandler) Delete(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), tid, ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *RoleHandler) Get(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
rid := c.Param("id")
|
||||
if rid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
r, err := h.svc.Get(c.Request.Context(), tid, rid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
func (h *RoleHandler) List(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
name := c.Query("name")
|
||||
code := c.Query("code")
|
||||
page := atoiDef(c.Query("page"), 1)
|
||||
pageSize := atoiDef(c.Query("page_size"), 10)
|
||||
resp, err := h.svc.List(c.Request.Context(), tid, name, code, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type assignMenusBody struct {
|
||||
MenuIDs []string `json:"menu_ids"`
|
||||
}
|
||||
|
||||
func (h *RoleHandler) AssignMenus(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
rid := c.Param("id")
|
||||
if rid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body assignMenusBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.AssignMenus(c.Request.Context(), tid, rid, body.MenuIDs, headerGrantorUserID(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TenantHandler struct {
|
||||
svc service.TenantService
|
||||
}
|
||||
|
||||
func NewTenantHandler(svc service.TenantService) *TenantHandler {
|
||||
return &TenantHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *TenantHandler) Create(c *gin.Context) {
|
||||
var req service.CreateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t, err := h.svc.Create(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req service.UpdateTenantRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
t, err := h.svc.Update(c.Request.Context(), id, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) Delete(c *gin.Context) {
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
t, err := h.svc.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) List(c *gin.Context) {
|
||||
name := c.Query("name")
|
||||
code := c.Query("code")
|
||||
var status *int16
|
||||
if s := c.Query("status"); s != "" {
|
||||
v64, err := strconv.ParseInt(s, 10, 16)
|
||||
if err == nil {
|
||||
v := int16(v64)
|
||||
status = &v
|
||||
}
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
resp, err := h.svc.List(c.Request.Context(), name, code, status, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
svc service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(svc service.UserService) *UserHandler {
|
||||
return &UserHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *UserHandler) Create(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var req service.CreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Create(c.Request.Context(), tid, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, u)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Update(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
uid := c.Param("id")
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req service.UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Update(c.Request.Context(), tid, uid, &req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Delete(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), tid, ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Get(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
uid := c.Param("id")
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Get(c.Request.Context(), tid, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
tid := headerTenantID(c)
|
||||
q := &service.UserListQuery{
|
||||
Keyword: c.Query("keyword"),
|
||||
Page: atoiDef(c.Query("page"), 1),
|
||||
PageSize: atoiDef(c.Query("page_size"), 10),
|
||||
}
|
||||
if s := c.Query("dept_id"); s != "" {
|
||||
q.DeptID = &s
|
||||
}
|
||||
if s := c.Query("role_id"); s != "" {
|
||||
q.RoleID = &s
|
||||
}
|
||||
if s := c.Query("status"); s != "" {
|
||||
v64, err := strconv.ParseInt(s, 10, 16)
|
||||
if err == nil {
|
||||
v := int16(v64)
|
||||
q.Status = &v
|
||||
}
|
||||
}
|
||||
resp, err := h.svc.List(c.Request.Context(), tid, q)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *UserHandler) DataScope(c *gin.Context) {
|
||||
uid := headerUserID(c)
|
||||
if uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 X-User-ID"})
|
||||
return
|
||||
}
|
||||
ds, err := h.svc.DataScopeForUser(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data_scope": ds})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/iam/handler"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type IamRoutes struct {
|
||||
tenantHandler *handler.TenantHandler
|
||||
deptHandler *handler.DeptHandler
|
||||
roleHandler *handler.RoleHandler
|
||||
userHandler *handler.UserHandler
|
||||
menuHandler *handler.MenuHandler
|
||||
}
|
||||
|
||||
func NewIamRoutes(tenantHandler *handler.TenantHandler, deptHandler *handler.DeptHandler, roleHandler *handler.RoleHandler, userHandler *handler.UserHandler, menuHandler *handler.MenuHandler) *IamRoutes {
|
||||
return &IamRoutes{
|
||||
tenantHandler: tenantHandler,
|
||||
deptHandler: deptHandler,
|
||||
roleHandler: roleHandler,
|
||||
userHandler: userHandler,
|
||||
menuHandler: menuHandler,
|
||||
}
|
||||
}
|
||||
// TODO 添加注册信息
|
||||
func (s *IamRoutes) Register(engine *gin.Engine, apiGroup *gin.RouterGroup) {
|
||||
// group :=engine.Group("/iam")
|
||||
group := apiGroup.Group("/iam")
|
||||
s.registerTenantRoutes(group)
|
||||
s.registerDeptRoutes(group)
|
||||
s.registerRoleRoutes(group)
|
||||
s.registerUserRoutes(group)
|
||||
s.registerMenuRoutes(group)
|
||||
}
|
||||
|
||||
func (s *IamRoutes) registerTenantRoutes(group *gin.RouterGroup) {
|
||||
tg := group.Group("/tenant")
|
||||
{
|
||||
tg.POST("/create", s.tenantHandler.Create)
|
||||
tg.PUT("/update/:id", s.tenantHandler.Update)
|
||||
tg.DELETE("/delete-batch", s.tenantHandler.Delete)
|
||||
tg.GET("/get/:id", s.tenantHandler.Get)
|
||||
tg.GET("/list", s.tenantHandler.List)
|
||||
}
|
||||
}
|
||||
func (s *IamRoutes) registerDeptRoutes(group *gin.RouterGroup) {
|
||||
dg := group.Group("/dept")
|
||||
{
|
||||
dg.POST("/create", s.deptHandler.Create)
|
||||
dg.PUT("/update/:id", s.deptHandler.Update)
|
||||
dg.DELETE("/delete-batch", s.deptHandler.Delete)
|
||||
dg.GET("/get/:id", s.deptHandler.Get)
|
||||
dg.GET("/tree", s.deptHandler.Tree)
|
||||
}
|
||||
}
|
||||
func (s *IamRoutes) registerRoleRoutes(group *gin.RouterGroup) {
|
||||
rg := group.Group("/role")
|
||||
{
|
||||
rg.POST("/create", s.roleHandler.Create)
|
||||
rg.PUT("/update/:id", s.roleHandler.Update)
|
||||
rg.DELETE("/delete-batch", s.roleHandler.Delete)
|
||||
rg.GET("/get/:id", s.roleHandler.Get)
|
||||
rg.GET("/list", s.roleHandler.List)
|
||||
}
|
||||
}
|
||||
func (s *IamRoutes) registerUserRoutes(group *gin.RouterGroup) {
|
||||
ug := group.Group("/user")
|
||||
{
|
||||
ug.POST("/create", s.userHandler.Create)
|
||||
ug.PUT("/update/:id", s.userHandler.Update)
|
||||
ug.DELETE("/delete-batch", s.userHandler.Delete)
|
||||
ug.GET("/get/:id", s.userHandler.Get)
|
||||
ug.GET("/list", s.userHandler.List)
|
||||
}
|
||||
}
|
||||
func (s *IamRoutes) registerMenuRoutes(group *gin.RouterGroup) {
|
||||
mg := group.Group("/menu")
|
||||
{
|
||||
mg.POST("/create", s.menuHandler.Create)
|
||||
mg.PUT("/update/:id", s.menuHandler.Update)
|
||||
mg.DELETE("/delete-batch", s.menuHandler.Delete)
|
||||
mg.GET("/get/:id", s.menuHandler.Get)
|
||||
mg.GET("/tree", s.menuHandler.Tree)
|
||||
mg.GET("/nav", s.menuHandler.Nav)
|
||||
mg.GET("/perms", s.menuHandler.Perms)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeptRepository 部门数据访问
|
||||
type DeptRepository interface {
|
||||
Create(ctx context.Context, d *entity.Dept) error
|
||||
Update(ctx context.Context, d *entity.Dept) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetByID(ctx context.Context, id string) (*entity.Dept, error)
|
||||
ListByTenant(ctx context.Context, tenantID string) ([]entity.Dept, error)
|
||||
CountChildren(ctx context.Context, id string) (int64, error)
|
||||
ExistsSiblingName(ctx context.Context, tenantID, parentID, name string, excludeID string) (bool, error)
|
||||
FindRoot(ctx context.Context, tenantID string) (*entity.Dept, error)
|
||||
UpdatePath(ctx context.Context, id string, path string) error
|
||||
}
|
||||
|
||||
type deptRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDeptRepository(db *gorm.DB) DeptRepository {
|
||||
return &deptRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *deptRepository) Create(ctx context.Context, d *entity.Dept) error {
|
||||
return r.db.WithContext(ctx).Create(d).Error
|
||||
}
|
||||
|
||||
func (r *deptRepository) Update(ctx context.Context, d *entity.Dept) error {
|
||||
return r.db.WithContext(ctx).Save(d).Error
|
||||
}
|
||||
|
||||
func (r *deptRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&entity.Dept{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *deptRepository) GetByID(ctx context.Context, id string) (*entity.Dept, error) {
|
||||
var out entity.Dept
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *deptRepository) ListByTenant(ctx context.Context, tenantID string) ([]entity.Dept, error) {
|
||||
var rows []entity.Dept
|
||||
err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Order("sort_order ASC, created_at ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *deptRepository) CountChildren(ctx context.Context, id string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.Dept{}).Where("parent_id = ?", id).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *deptRepository) ExistsSiblingName(ctx context.Context, tenantID, parentID, name string, excludeID string) (bool, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Dept{}).Where("tenant_id = ? AND parent_id = ? AND dept_name = ?", tenantID, parentID, name)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
err := q.Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *deptRepository) FindRoot(ctx context.Context, tenantID string) (*entity.Dept, error) {
|
||||
var out entity.Dept
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("tenant_id = ? AND (parent_id = '' OR parent_id = '0')", tenantID).
|
||||
First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *deptRepository) UpdatePath(ctx context.Context, id string, path string) error {
|
||||
return r.db.WithContext(ctx).Model(&entity.Dept{}).Where("id = ?", id).Update("dept_path", path).Error
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package repository
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrInvalidState = errors.New("invalid state")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MenuRepository 菜单
|
||||
type MenuRepository interface {
|
||||
Create(ctx context.Context, m *entity.Menu) error
|
||||
Update(ctx context.Context, m *entity.Menu) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetByID(ctx context.Context, id string) (*entity.Menu, error)
|
||||
ListAll(ctx context.Context) ([]entity.Menu, error)
|
||||
ListByType(ctx context.Context, menuType *int16) ([]entity.Menu, error)
|
||||
ExistsPerms(ctx context.Context, perms string, excludeID string) (bool, error)
|
||||
CountChildren(ctx context.Context, parentID string) (int64, error)
|
||||
CountRoleRefs(ctx context.Context, menuID string) (int64, error)
|
||||
ListByPerms(ctx context.Context, perms string) ([]entity.Menu, error)
|
||||
ListIDsByPermsIn(ctx context.Context, perms []string) ([]string, error)
|
||||
}
|
||||
|
||||
type menuRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMenuRepository(db *gorm.DB) MenuRepository {
|
||||
return &menuRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *menuRepository) Create(ctx context.Context, m *entity.Menu) error {
|
||||
return r.db.WithContext(ctx).Create(m).Error
|
||||
}
|
||||
|
||||
func (r *menuRepository) Update(ctx context.Context, m *entity.Menu) error {
|
||||
return r.db.WithContext(ctx).Save(m).Error
|
||||
}
|
||||
|
||||
func (r *menuRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&entity.Menu{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *menuRepository) GetByID(ctx context.Context, id string) (*entity.Menu, error) {
|
||||
var out entity.Menu
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *menuRepository) ListAll(ctx context.Context) ([]entity.Menu, error) {
|
||||
var rows []entity.Menu
|
||||
err := r.db.WithContext(ctx).Order("sort_order ASC, created_at ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) ListByType(ctx context.Context, menuType *int16) ([]entity.Menu, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Menu{})
|
||||
if menuType != nil {
|
||||
q = q.Where("menu_type = ?", *menuType)
|
||||
}
|
||||
var rows []entity.Menu
|
||||
err := q.Order("sort_order ASC, created_at ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) ExistsPerms(ctx context.Context, perms string, excludeID string) (bool, error) {
|
||||
if perms == "" {
|
||||
return false, nil
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&entity.Menu{}).Where("perms = ?", perms)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
err := q.Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) CountChildren(ctx context.Context, parentID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.Menu{}).Where("parent_id = ?", parentID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) CountRoleRefs(ctx context.Context, menuID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.RoleMenu{}).Where("menu_id = ?", menuID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) ListByPerms(ctx context.Context, perms string) ([]entity.Menu, error) {
|
||||
var rows []entity.Menu
|
||||
err := r.db.WithContext(ctx).Where("perms = ?", perms).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *menuRepository) ListIDsByPermsIn(ctx context.Context, perms []string) ([]string, error) {
|
||||
if len(perms) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&entity.Menu{}).Where("perms IN ?", perms).Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RoleRepository 角色与角色菜单
|
||||
type RoleRepository interface {
|
||||
Create(ctx context.Context, r *entity.Role) error
|
||||
Update(ctx context.Context, r *entity.Role) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetByID(ctx context.Context, id string) (*entity.Role, error)
|
||||
List(ctx context.Context, tenantID string, name, code string, page, pageSize int) ([]entity.Role, int64, error)
|
||||
ExistsCode(ctx context.Context, tenantID string, code string, excludeID string) (bool, error)
|
||||
CountUsers(ctx context.Context, roleID string) (int64, error)
|
||||
ReplaceRoleMenus(ctx context.Context, roleID string, menuIDs []string) error
|
||||
ListMenuIDsByRole(ctx context.Context, roleID string) ([]string, error)
|
||||
ListMenuIDsByRoles(ctx context.Context, roleIDs []string) ([]string, error)
|
||||
ListRolesByUser(ctx context.Context, userID string) ([]entity.Role, error)
|
||||
}
|
||||
|
||||
type roleRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRoleRepository(db *gorm.DB) RoleRepository {
|
||||
return &roleRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *roleRepository) Create(ctx context.Context, row *entity.Role) error {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
|
||||
func (r *roleRepository) Update(ctx context.Context, row *entity.Role) error {
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
func (r *roleRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&entity.Role{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *roleRepository) GetByID(ctx context.Context, id string) (*entity.Role, error) {
|
||||
var out entity.Role
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *roleRepository) List(ctx context.Context, tenantID string, name, code string, page, pageSize int) ([]entity.Role, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Role{}).Where("tenant_id = ?", tenantID)
|
||||
if name != "" {
|
||||
q = q.Where("role_name LIKE ?", "%"+name+"%")
|
||||
}
|
||||
if code != "" {
|
||||
q = q.Where("role_code LIKE ?", "%"+code+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []entity.Role
|
||||
err := q.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *roleRepository) ExistsCode(ctx context.Context, tenantID string, code string, excludeID string) (bool, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Role{}).Where("tenant_id = ? AND role_code = ?", tenantID, code)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
err := q.Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *roleRepository) CountUsers(ctx context.Context, roleID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.UserRole{}).Where("role_id = ?", roleID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *roleRepository) ReplaceRoleMenus(ctx context.Context, roleID string, menuIDs []string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("role_id = ?", roleID).Delete(&entity.RoleMenu{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, mid := range menuIDs {
|
||||
rm := entity.RoleMenu{ID: id.New(), RoleID: roleID, MenuID: mid}
|
||||
if err := tx.Create(&rm).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *roleRepository) ListMenuIDsByRole(ctx context.Context, roleID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&entity.RoleMenu{}).Where("role_id = ?", roleID).Pluck("menu_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *roleRepository) ListMenuIDsByRoles(ctx context.Context, roleIDs []string) ([]string, error) {
|
||||
if len(roleIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []string
|
||||
err := r.db.WithContext(ctx).Model(&entity.RoleMenu{}).Where("role_id IN ?", roleIDs).Pluck("menu_id", &raw).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
var ids []string
|
||||
for _, menuID := range raw {
|
||||
if _, ok := seen[menuID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[menuID] = struct{}{}
|
||||
ids = append(ids, menuID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *roleRepository) ListRolesByUser(ctx context.Context, userID string) ([]entity.Role, error) {
|
||||
var roles []entity.Role
|
||||
err := r.db.WithContext(ctx).Table("iam_role").
|
||||
Joins("JOIN iam_user_role ur ON ur.role_id = iam_role.id").
|
||||
Where("ur.user_id = ?", userID).
|
||||
Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantRepository 租户数据访问
|
||||
type TenantRepository interface {
|
||||
Create(ctx context.Context, t *entity.Tenant) error
|
||||
Update(ctx context.Context, t *entity.Tenant) error
|
||||
GetByID(ctx context.Context, id string) (*entity.Tenant, error)
|
||||
GetByCode(ctx context.Context, code string) (*entity.Tenant, error)
|
||||
List(ctx context.Context, name, code string, status *int16, page, pageSize int) ([]entity.Tenant, int64, error)
|
||||
CountUsers(ctx context.Context, tenantID string) (int64, error)
|
||||
CountDepts(ctx context.Context, tenantID string) (int64, error)
|
||||
ExistsCode(ctx context.Context, code string, excludeID string) (bool, error)
|
||||
}
|
||||
|
||||
type tenantRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTenantRepository(db *gorm.DB) TenantRepository {
|
||||
return &tenantRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *tenantRepository) Create(ctx context.Context, t *entity.Tenant) error {
|
||||
return r.db.WithContext(ctx).Create(t).Error
|
||||
}
|
||||
|
||||
func (r *tenantRepository) Update(ctx context.Context, t *entity.Tenant) error {
|
||||
return r.db.WithContext(ctx).Save(t).Error
|
||||
}
|
||||
|
||||
func (r *tenantRepository) GetByID(ctx context.Context, id string) (*entity.Tenant, error) {
|
||||
var out entity.Tenant
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) GetByCode(ctx context.Context, code string) (*entity.Tenant, error) {
|
||||
var out entity.Tenant
|
||||
err := r.db.WithContext(ctx).Where("tenant_code = ?", code).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) List(ctx context.Context, name, code string, status *int16, page, pageSize int) ([]entity.Tenant, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Tenant{})
|
||||
if name != "" {
|
||||
q = q.Where("tenant_name LIKE ?", "%"+name+"%")
|
||||
}
|
||||
if code != "" {
|
||||
q = q.Where("tenant_code LIKE ?", "%"+code+"%")
|
||||
}
|
||||
if status != nil {
|
||||
q = q.Where("status = ?", *status)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []entity.Tenant
|
||||
err := q.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *tenantRepository) CountUsers(ctx context.Context, tenantID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.User{}).Where("tenant_id = ?", tenantID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *tenantRepository) CountDepts(ctx context.Context, tenantID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Model(&entity.Dept{}).Where("tenant_id = ?", tenantID).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *tenantRepository) ExistsCode(ctx context.Context, code string, excludeID string) (bool, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.Tenant{}).Where("tenant_code = ?", code)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
err := q.Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserRepository 用户数据访问
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, u *entity.User) error
|
||||
Update(ctx context.Context, u *entity.User) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
GetByID(ctx context.Context, id string) (*entity.User, error)
|
||||
GetByUserName(ctx context.Context, tenantID string, userName string) (*entity.User, error)
|
||||
ExistsUserName(ctx context.Context, tenantID string, userName string, excludeID string) (bool, error)
|
||||
CountByDept(ctx context.Context, deptID string) (int64, error)
|
||||
List(ctx context.Context, tenantID string, deptID *string, roleID *string, keyword string, status *int16, page, pageSize int) ([]entity.User, int64, error)
|
||||
ReplaceUserDepts(ctx context.Context, userID string, primaryDept string, deptIDs []string) error
|
||||
ReplaceUserRoles(ctx context.Context, userID string, roleIDs []string) error
|
||||
ListRoleIDs(ctx context.Context, userID string) ([]string, error)
|
||||
ListDeptIDs(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, u *entity.User) error {
|
||||
return r.db.WithContext(ctx).Create(u).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, u *entity.User) error {
|
||||
return r.db.WithContext(ctx).Save(u).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&entity.User{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
var out entity.User
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByUserName(ctx context.Context, tenantID string, userName string) (*entity.User, error) {
|
||||
var out entity.User
|
||||
err := r.db.WithContext(ctx).Where("tenant_id = ? AND user_name = ?", tenantID, userName).First(&out).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) ExistsUserName(ctx context.Context, tenantID string, userName string, excludeID string) (bool, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.User{}).Where("tenant_id = ? AND user_name = ?", tenantID, userName)
|
||||
if excludeID != "" {
|
||||
q = q.Where("id <> ?", excludeID)
|
||||
}
|
||||
var n int64
|
||||
err := q.Count(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (r *userRepository) CountByDept(ctx context.Context, deptID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT id FROM iam_user WHERE dept_id = ? AND deleted_at IS NULL
|
||||
UNION
|
||||
SELECT user_id FROM iam_user_dept WHERE dept_id = ?
|
||||
) t`, deptID, deptID).Scan(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *userRepository) List(ctx context.Context, tenantID string, deptID *string, roleID *string, keyword string, status *int16, page, pageSize int) ([]entity.User, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&entity.User{}).Where("tenant_id = ?", tenantID)
|
||||
if deptID != nil {
|
||||
d := *deptID
|
||||
q = q.Where("dept_id = ? OR id IN (SELECT user_id FROM iam_user_dept WHERE dept_id = ?)", d, d)
|
||||
}
|
||||
if roleID != nil {
|
||||
sub := r.db.WithContext(ctx).Model(&entity.UserRole{}).Select("user_id").Where("role_id = ?", *roleID)
|
||||
q = q.Where("id IN (?)", sub)
|
||||
}
|
||||
if keyword != "" {
|
||||
kw := "%" + keyword + "%"
|
||||
q = q.Where("user_name LIKE ? OR real_name LIKE ? OR phone LIKE ? OR email LIKE ?", kw, kw, kw, kw)
|
||||
}
|
||||
if status != nil {
|
||||
q = q.Where("status = ?", *status)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []entity.User
|
||||
err := q.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *userRepository) ReplaceUserDepts(ctx context.Context, userID string, primaryDept string, deptIDs []string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&entity.UserDept{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, did := range deptIDs {
|
||||
if _, ok := seen[did]; ok {
|
||||
continue
|
||||
}
|
||||
seen[did] = struct{}{}
|
||||
ud := entity.UserDept{
|
||||
ID: id.New(),
|
||||
UserID: userID,
|
||||
DeptID: did,
|
||||
IsPrimary: did == primaryDept,
|
||||
}
|
||||
if err := tx.Create(&ud).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(deptIDs) == 0 && primaryDept != "" {
|
||||
ud := entity.UserDept{ID: id.New(), UserID: userID, DeptID: primaryDept, IsPrimary: true}
|
||||
return tx.Create(&ud).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userRepository) ReplaceUserRoles(ctx context.Context, userID string, roleIDs []string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&entity.UserRole{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rid := range roleIDs {
|
||||
ur := entity.UserRole{ID: id.New(), UserID: userID, RoleID: rid}
|
||||
if err := tx.Create(&ur).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userRepository) ListRoleIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&entity.UserRole{}).Where("user_id = ?", userID).Pluck("role_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func (r *userRepository) ListDeptIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
var ids []string
|
||||
err := r.db.WithContext(ctx).Model(&entity.UserDept{}).Where("user_id = ?", userID).Pluck("dept_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package service
|
||||
|
||||
// DefaultTenantAdminRoleCode 新租户初始化时的单位管理员角色编码
|
||||
const DefaultTenantAdminRoleCode = "tenant_admin"
|
||||
@@ -0,0 +1,326 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
)
|
||||
|
||||
// DeptService 部门
|
||||
type DeptService interface {
|
||||
Tree(ctx context.Context, tenantID string, keyword string, leaderID *string) ([]DeptNode, error)
|
||||
Create(ctx context.Context, tenantID string, req *CreateDeptRequest) (*entity.Dept, error)
|
||||
Update(ctx context.Context, tenantID string, id string, req *UpdateDeptRequest) (*entity.Dept, error)
|
||||
Delete(ctx context.Context, tenantID string, ids []string) error
|
||||
Get(ctx context.Context, tenantID string, id string) (*entity.Dept, error)
|
||||
}
|
||||
|
||||
type CreateDeptRequest struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
DeptName string `json:"dept_name" binding:"required,max=128"`
|
||||
LeaderID *string `json:"leader_id"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type UpdateDeptRequest struct {
|
||||
ParentID *string `json:"parent_id"`
|
||||
DeptName *string `json:"dept_name" binding:"omitempty,max=128"`
|
||||
LeaderID *string `json:"leader_id"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// DeptNode 树节点
|
||||
type DeptNode struct {
|
||||
entity.Dept
|
||||
Children []DeptNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type deptService struct {
|
||||
depts repository.DeptRepository
|
||||
users repository.UserRepository
|
||||
}
|
||||
|
||||
func NewDeptService(depts repository.DeptRepository, users repository.UserRepository) DeptService {
|
||||
return &deptService{depts: depts, users: users}
|
||||
}
|
||||
|
||||
func isDeptRoot(d *entity.Dept) bool {
|
||||
return d.ParentID == "" || d.ParentID == "0"
|
||||
}
|
||||
|
||||
func (s *deptService) Tree(ctx context.Context, tenantID string, keyword string, leaderID *string) ([]DeptNode, error) {
|
||||
rows, err := s.depts.ListByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := rows
|
||||
if keyword != "" || leaderID != nil {
|
||||
filtered = make([]entity.Dept, 0)
|
||||
for _, d := range rows {
|
||||
if keyword != "" && !strings.Contains(d.DeptName, keyword) {
|
||||
continue
|
||||
}
|
||||
if leaderID != nil && (d.LeaderID == nil || *d.LeaderID != *leaderID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
if keyword != "" || leaderID != nil {
|
||||
filtered = s.includeAncestors(rows, filtered)
|
||||
}
|
||||
}
|
||||
return buildDeptTree(filtered, ""), nil
|
||||
}
|
||||
|
||||
func (s *deptService) includeAncestors(all []entity.Dept, matched []entity.Dept) []entity.Dept {
|
||||
idSet := map[string]struct{}{}
|
||||
byID := map[string]entity.Dept{}
|
||||
for _, d := range all {
|
||||
byID[d.ID] = d
|
||||
}
|
||||
for _, d := range matched {
|
||||
cur := d
|
||||
for {
|
||||
idSet[cur.ID] = struct{}{}
|
||||
if isDeptRoot(&cur) {
|
||||
break
|
||||
}
|
||||
p, ok := byID[cur.ParentID]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
cur = p
|
||||
}
|
||||
}
|
||||
out := make([]entity.Dept, 0, len(idSet))
|
||||
for _, d := range all {
|
||||
if _, ok := idSet[d.ID]; ok {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildDeptTree(rows []entity.Dept, parentID string) []DeptNode {
|
||||
children := map[string][]entity.Dept{}
|
||||
for _, d := range rows {
|
||||
pid := d.ParentID
|
||||
if d.ParentID == "0" {
|
||||
pid = ""
|
||||
}
|
||||
children[pid] = append(children[pid], d)
|
||||
}
|
||||
var walk func(pid string) []DeptNode
|
||||
walk = func(pid string) []DeptNode {
|
||||
list := children[pid]
|
||||
out := make([]DeptNode, 0, len(list))
|
||||
for _, d := range list {
|
||||
out = append(out, DeptNode{Dept: d, Children: walk(d.ID)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return walk(parentID)
|
||||
}
|
||||
|
||||
func (s *deptService) Create(ctx context.Context, tenantID string, req *CreateDeptRequest) (*entity.Dept, error) {
|
||||
parentKey := req.ParentID
|
||||
if parentKey == "0" {
|
||||
parentKey = ""
|
||||
}
|
||||
ok, err := s.depts.ExistsSiblingName(ctx, tenantID, parentKey, req.DeptName, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("同级部门名称已存在")
|
||||
}
|
||||
d := &entity.Dept{
|
||||
ID: id.New(),
|
||||
TenantID: tenantID,
|
||||
ParentID: parentKey,
|
||||
DeptName: req.DeptName,
|
||||
LeaderID: req.LeaderID,
|
||||
SortOrder: req.SortOrder,
|
||||
Status: 1,
|
||||
}
|
||||
if err := s.depts.Create(ctx, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fmt.Sprintf("/%s/", d.ID)
|
||||
if parentKey != "" {
|
||||
p, err := s.depts.GetByID(ctx, parentKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("父部门不属于当前租户")
|
||||
}
|
||||
base := p.DeptPath
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("/%s/", p.ID)
|
||||
}
|
||||
path = base + fmt.Sprintf("%s/", d.ID)
|
||||
}
|
||||
if err := s.depts.UpdatePath(ctx, d.ID, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.DeptPath = path
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *deptService) Update(ctx context.Context, tenantID string, id string, req *UpdateDeptRequest) (*entity.Dept, error) {
|
||||
d, err := s.depts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("部门不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if d.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("部门不属于当前租户")
|
||||
}
|
||||
if isDeptRoot(d) {
|
||||
if req.ParentID != nil && *req.ParentID != "" && *req.ParentID != "0" {
|
||||
return nil, fmt.Errorf("根部门禁止移动")
|
||||
}
|
||||
if req.DeptName != nil && *req.DeptName != "" && *req.DeptName != d.DeptName {
|
||||
return nil, fmt.Errorf("根部门禁止重命名")
|
||||
}
|
||||
}
|
||||
curParent := d.ParentID
|
||||
if curParent == "0" {
|
||||
curParent = ""
|
||||
}
|
||||
var newParentForName string
|
||||
if req.ParentID != nil {
|
||||
np := *req.ParentID
|
||||
if np == "0" {
|
||||
np = ""
|
||||
}
|
||||
newParentForName = np
|
||||
} else {
|
||||
newParentForName = curParent
|
||||
}
|
||||
if req.DeptName != nil && *req.DeptName != "" {
|
||||
ok, err := s.depts.ExistsSiblingName(ctx, tenantID, newParentForName, *req.DeptName, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("同级部门名称已存在")
|
||||
}
|
||||
d.DeptName = *req.DeptName
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
npID := *req.ParentID
|
||||
if npID == "0" {
|
||||
npID = ""
|
||||
}
|
||||
if npID != curParent {
|
||||
if npID == id {
|
||||
return nil, fmt.Errorf("不能将部门移动到自身之下")
|
||||
}
|
||||
if npID != "" {
|
||||
if s.isDescendant(ctx, id, npID) {
|
||||
return nil, fmt.Errorf("禁止移动至子部门(防环)")
|
||||
}
|
||||
np, err := s.depts.GetByID(ctx, npID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("父部门无效")
|
||||
}
|
||||
if np.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("父部门不属于当前租户")
|
||||
}
|
||||
d.ParentID = npID
|
||||
base := np.DeptPath
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("/%s/", np.ID)
|
||||
}
|
||||
d.DeptPath = base + fmt.Sprintf("%s/", d.ID)
|
||||
} else {
|
||||
d.ParentID = ""
|
||||
d.DeptPath = fmt.Sprintf("/%s/", d.ID)
|
||||
}
|
||||
_ = s.depts.UpdatePath(ctx, d.ID, d.DeptPath)
|
||||
}
|
||||
}
|
||||
if req.LeaderID != nil {
|
||||
d.LeaderID = req.LeaderID
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
d.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.depts.Update(ctx, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *deptService) isDescendant(ctx context.Context, rootID, nodeID string) bool {
|
||||
if nodeID == rootID {
|
||||
return true
|
||||
}
|
||||
cur, err := s.depts.GetByID(ctx, nodeID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < 64 && cur.ParentID != "" && cur.ParentID != "0"; i++ {
|
||||
if cur.ParentID == rootID {
|
||||
return true
|
||||
}
|
||||
cur, err = s.depts.GetByID(ctx, cur.ParentID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *deptService) Delete(ctx context.Context, tenantID string, ids []string) error {
|
||||
for _, did := range ids {
|
||||
d, err := s.depts.GetByID(ctx, did)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.TenantID != tenantID {
|
||||
return fmt.Errorf("部门 %s 不属于当前租户", did)
|
||||
}
|
||||
if isDeptRoot(d) {
|
||||
return fmt.Errorf("根部门禁止删除")
|
||||
}
|
||||
n, err := s.depts.CountChildren(ctx, did)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("部门 %s 存在子部门", did)
|
||||
}
|
||||
uc, err := s.users.CountByDept(ctx, did)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uc > 0 {
|
||||
return fmt.Errorf("部门 %s 仍存在用户", did)
|
||||
}
|
||||
if err := s.depts.Delete(ctx, did); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *deptService) Get(ctx context.Context, tenantID string, id string) (*entity.Dept, error) {
|
||||
d, err := s.depts.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("部门不属于当前租户")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
)
|
||||
|
||||
// MenuService 菜单(全局资源)
|
||||
type MenuService interface {
|
||||
Create(ctx context.Context, req *CreateMenuRequest, isPlatform bool) (*entity.Menu, error)
|
||||
Update(ctx context.Context, mid string, req *UpdateMenuRequest, isPlatform bool) (*entity.Menu, error)
|
||||
Delete(ctx context.Context, ids []string, isPlatform bool) error
|
||||
Get(ctx context.Context, mid string) (*entity.Menu, error)
|
||||
Tree(ctx context.Context, menuType *int16) ([]MenuNode, error)
|
||||
NavForUser(ctx context.Context, userID string) ([]MenuNode, error)
|
||||
PermsForUser(ctx context.Context, userID string) ([]string, error)
|
||||
}
|
||||
|
||||
type CreateMenuRequest struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
MenuName string `json:"menu_name" binding:"required,max=128"`
|
||||
MenuType int16 `json:"menu_type" binding:"required"`
|
||||
Perms string `json:"perms"`
|
||||
Path string `json:"path"`
|
||||
Component string `json:"component"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsVisible bool `json:"is_visible"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
ExternalLink string `json:"external_link"`
|
||||
}
|
||||
|
||||
type UpdateMenuRequest struct {
|
||||
ParentID *string `json:"parent_id"`
|
||||
MenuName *string `json:"menu_name"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
IsVisible *bool `json:"is_visible"`
|
||||
Path *string `json:"path"`
|
||||
Component *string `json:"component"`
|
||||
Icon *string `json:"icon"`
|
||||
ExternalLink *string `json:"external_link"`
|
||||
Status *int16 `json:"status"`
|
||||
}
|
||||
|
||||
// MenuNode 菜单树节点
|
||||
type MenuNode struct {
|
||||
entity.Menu
|
||||
Children []MenuNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type menuService struct {
|
||||
menus repository.MenuRepository
|
||||
roles repository.RoleRepository
|
||||
users repository.UserRepository
|
||||
}
|
||||
|
||||
func NewMenuService(menus repository.MenuRepository, roles repository.RoleRepository, users repository.UserRepository) MenuService {
|
||||
return &menuService{menus: menus, roles: roles, users: users}
|
||||
}
|
||||
|
||||
func normalizeMenuParent(pid string) string {
|
||||
if pid == "0" {
|
||||
return ""
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
func (s *menuService) Create(ctx context.Context, req *CreateMenuRequest, isPlatform bool) (*entity.Menu, error) {
|
||||
if !isPlatform {
|
||||
return nil, repository.ErrForbidden
|
||||
}
|
||||
if req.Perms != "" {
|
||||
ok, err := s.menus.ExistsPerms(ctx, req.Perms, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("权限标识已存在")
|
||||
}
|
||||
}
|
||||
m := &entity.Menu{
|
||||
ID: id.New(),
|
||||
ParentID: normalizeMenuParent(req.ParentID),
|
||||
MenuName: req.MenuName,
|
||||
MenuType: req.MenuType,
|
||||
Perms: req.Perms,
|
||||
Path: req.Path,
|
||||
Component: req.Component,
|
||||
Icon: req.Icon,
|
||||
SortOrder: req.SortOrder,
|
||||
IsVisible: req.IsVisible,
|
||||
IsBuiltin: req.IsBuiltin,
|
||||
ExternalLink: req.ExternalLink,
|
||||
Status: 1,
|
||||
}
|
||||
if err := s.menus.Create(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *menuService) Update(ctx context.Context, mid string, req *UpdateMenuRequest, isPlatform bool) (*entity.Menu, error) {
|
||||
if !isPlatform {
|
||||
return nil, repository.ErrForbidden
|
||||
}
|
||||
m, err := s.menus.GetByID(ctx, mid)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("菜单不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if m.IsBuiltin {
|
||||
return nil, fmt.Errorf("系统内置菜单禁止修改")
|
||||
}
|
||||
if req.MenuName != nil {
|
||||
m.MenuName = *req.MenuName
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
m.SortOrder = *req.SortOrder
|
||||
}
|
||||
if req.IsVisible != nil {
|
||||
m.IsVisible = *req.IsVisible
|
||||
}
|
||||
if req.Path != nil {
|
||||
m.Path = *req.Path
|
||||
}
|
||||
if req.Component != nil {
|
||||
m.Component = *req.Component
|
||||
}
|
||||
if req.Icon != nil {
|
||||
m.Icon = *req.Icon
|
||||
}
|
||||
if req.ExternalLink != nil {
|
||||
m.ExternalLink = *req.ExternalLink
|
||||
}
|
||||
if req.Status != nil {
|
||||
m.Status = *req.Status
|
||||
}
|
||||
if err := s.menus.Update(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *menuService) Delete(ctx context.Context, ids []string, isPlatform bool) error {
|
||||
if !isPlatform {
|
||||
return repository.ErrForbidden
|
||||
}
|
||||
for _, mid := range ids {
|
||||
m, err := s.menus.GetByID(ctx, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.IsBuiltin {
|
||||
return fmt.Errorf("系统内置菜单禁止删除")
|
||||
}
|
||||
n, err := s.menus.CountChildren(ctx, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("存在子菜单,无法删除")
|
||||
}
|
||||
rn, err := s.menus.CountRoleRefs(ctx, mid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rn > 0 {
|
||||
return fmt.Errorf("菜单仍被角色引用")
|
||||
}
|
||||
if err := s.menus.Delete(ctx, mid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *menuService) Get(ctx context.Context, mid string) (*entity.Menu, error) {
|
||||
return s.menus.GetByID(ctx, mid)
|
||||
}
|
||||
|
||||
func (s *menuService) Tree(ctx context.Context, menuType *int16) ([]MenuNode, error) {
|
||||
rows, err := s.menus.ListByType(ctx, menuType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildMenuTreeRows(rows), nil
|
||||
}
|
||||
|
||||
func buildMenuTreeRows(rows []entity.Menu) []MenuNode {
|
||||
byParent := map[string][]entity.Menu{}
|
||||
for _, m := range rows {
|
||||
pid := normalizeMenuParent(m.ParentID)
|
||||
byParent[pid] = append(byParent[pid], m)
|
||||
}
|
||||
for k := range byParent {
|
||||
sort.Slice(byParent[k], func(i, j int) bool {
|
||||
if byParent[k][i].SortOrder != byParent[k][j].SortOrder {
|
||||
return byParent[k][i].SortOrder < byParent[k][j].SortOrder
|
||||
}
|
||||
return byParent[k][i].ID < byParent[k][j].ID
|
||||
})
|
||||
}
|
||||
var walk func(pid string) []MenuNode
|
||||
walk = func(pid string) []MenuNode {
|
||||
list := byParent[pid]
|
||||
out := make([]MenuNode, 0, len(list))
|
||||
for _, m := range list {
|
||||
out = append(out, MenuNode{Menu: m, Children: walk(m.ID)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return walk("")
|
||||
}
|
||||
|
||||
func (s *menuService) NavForUser(ctx context.Context, userID string) ([]MenuNode, error) {
|
||||
rids, err := s.users.ListRoleIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
menuIDs, err := s.roles.ListMenuIDsByRoles(ctx, rids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
for _, mid := range menuIDs {
|
||||
allowed[mid] = struct{}{}
|
||||
}
|
||||
pub, err := s.menus.ListByPerms(ctx, entity.PublicOverviewPerms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range pub {
|
||||
allowed[m.ID] = struct{}{}
|
||||
}
|
||||
all, err := s.menus.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[string]entity.Menu{}
|
||||
for _, m := range all {
|
||||
byID[m.ID] = m
|
||||
}
|
||||
for _, m := range all {
|
||||
if _, ok := allowed[m.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
cur := m
|
||||
for {
|
||||
pid := normalizeMenuParent(cur.ParentID)
|
||||
if pid == "" {
|
||||
break
|
||||
}
|
||||
p, ok := byID[pid]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
allowed[p.ID] = struct{}{}
|
||||
cur = p
|
||||
}
|
||||
}
|
||||
filtered := make([]entity.Menu, 0)
|
||||
for _, m := range all {
|
||||
if _, ok := allowed[m.ID]; ok && m.Status == 1 && m.IsVisible {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
tree := buildMenuTreeRows(filtered)
|
||||
return pruneEmptyDirs(tree), nil
|
||||
}
|
||||
|
||||
func pruneEmptyDirs(nodes []MenuNode) []MenuNode {
|
||||
out := make([]MenuNode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
ch := pruneEmptyDirs(n.Children)
|
||||
if n.MenuType == 1 && len(ch) == 0 {
|
||||
continue
|
||||
}
|
||||
n.Children = ch
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *menuService) PermsForUser(ctx context.Context, userID string) ([]string, error) {
|
||||
rids, err := s.users.ListRoleIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mids, err := s.roles.ListMenuIDsByRoles(ctx, rids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := s.menus.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idset := map[string]struct{}{}
|
||||
for _, mid := range mids {
|
||||
idset[mid] = struct{}{}
|
||||
}
|
||||
var perms []string
|
||||
for _, m := range all {
|
||||
if _, ok := idset[m.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if m.Perms != "" {
|
||||
perms = append(perms, m.Perms)
|
||||
}
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
)
|
||||
|
||||
// RoleService 角色
|
||||
type RoleService interface {
|
||||
Create(ctx context.Context, tenantID string, req *CreateRoleRequest, grantorUserID *string) (*entity.Role, error)
|
||||
Update(ctx context.Context, tenantID string, rid string, req *UpdateRoleRequest, grantorUserID *string) (*entity.Role, error)
|
||||
Delete(ctx context.Context, tenantID string, ids []string) error
|
||||
Get(ctx context.Context, tenantID string, rid string) (*entity.Role, error)
|
||||
List(ctx context.Context, tenantID string, name, code string, page, pageSize int) (*RoleListResponse, error)
|
||||
AssignMenus(ctx context.Context, tenantID string, roleID string, menuIDs []string, grantorUserID *string) error
|
||||
}
|
||||
|
||||
type CreateRoleRequest struct {
|
||||
RoleCode string `json:"role_code" binding:"required,max=64"`
|
||||
RoleName string `json:"role_name" binding:"required,max=128"`
|
||||
DataScope int16 `json:"data_scope" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
MenuIDs []string `json:"menu_ids"`
|
||||
}
|
||||
|
||||
type UpdateRoleRequest struct {
|
||||
RoleName *string `json:"role_name"`
|
||||
DataScope *int16 `json:"data_scope"`
|
||||
Description *string `json:"description"`
|
||||
MenuIDs []string `json:"menu_ids"`
|
||||
}
|
||||
|
||||
type RoleListResponse struct {
|
||||
Items []entity.Role `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type roleService struct {
|
||||
roles repository.RoleRepository
|
||||
users repository.UserRepository
|
||||
menus repository.MenuRepository
|
||||
}
|
||||
|
||||
func NewRoleService(roles repository.RoleRepository, users repository.UserRepository, menus repository.MenuRepository) RoleService {
|
||||
return &roleService{roles: roles, users: users, menus: menus}
|
||||
}
|
||||
|
||||
func (s *roleService) grantorMenuSet(ctx context.Context, grantorUserID string) (map[string]struct{}, error) {
|
||||
rids, err := s.users.ListRoleIDs(ctx, grantorUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := s.roles.ListMenuIDsByRoles(ctx, rids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]struct{}, len(ids))
|
||||
for _, mid := range ids {
|
||||
m[mid] = struct{}{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *roleService) assertMenuSubset(ctx context.Context, grantorUserID *string, menuIDs []string) error {
|
||||
if grantorUserID == nil || *grantorUserID == "" {
|
||||
return nil
|
||||
}
|
||||
allowed, err := s.grantorMenuSet(ctx, *grantorUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, mid := range menuIDs {
|
||||
if _, ok := allowed[mid]; !ok {
|
||||
return fmt.Errorf("防越权: 不能分配自身未拥有的菜单权限 (menu_id=%s)", mid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleService) Create(ctx context.Context, tenantID string, req *CreateRoleRequest, grantorUserID *string) (*entity.Role, error) {
|
||||
ok, err := s.roles.ExistsCode(ctx, tenantID, req.RoleCode, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("角色编码已存在")
|
||||
}
|
||||
if err := s.assertMenuSubset(ctx, grantorUserID, req.MenuIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &entity.Role{
|
||||
ID: id.New(),
|
||||
TenantID: tenantID,
|
||||
RoleCode: req.RoleCode,
|
||||
RoleName: req.RoleName,
|
||||
DataScope: req.DataScope,
|
||||
Description: req.Description,
|
||||
Status: 1,
|
||||
}
|
||||
if err := s.roles.Create(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.MenuIDs) > 0 {
|
||||
if err := s.roles.ReplaceRoleMenus(ctx, r.ID, req.MenuIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *roleService) Update(ctx context.Context, tenantID string, rid string, req *UpdateRoleRequest, grantorUserID *string) (*entity.Role, error) {
|
||||
r, err := s.roles.GetByID(ctx, rid)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("角色不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if r.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("角色不属于当前租户")
|
||||
}
|
||||
if r.IsBuiltin {
|
||||
// 内置角色仅允许改部分字段(MVP:允许改名称与数据范围与菜单需业务再定)
|
||||
}
|
||||
if req.RoleName != nil {
|
||||
r.RoleName = *req.RoleName
|
||||
}
|
||||
if req.DataScope != nil {
|
||||
r.DataScope = *req.DataScope
|
||||
}
|
||||
if req.Description != nil {
|
||||
r.Description = *req.Description
|
||||
}
|
||||
if err := s.roles.Update(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.MenuIDs != nil {
|
||||
if err := s.assertMenuSubset(ctx, grantorUserID, req.MenuIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.roles.ReplaceRoleMenus(ctx, r.ID, req.MenuIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *roleService) Delete(ctx context.Context, tenantID string, ids []string) error {
|
||||
for _, rid := range ids {
|
||||
r, err := s.roles.GetByID(ctx, rid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.TenantID != tenantID {
|
||||
return fmt.Errorf("角色 %s 不属于当前租户", rid)
|
||||
}
|
||||
if r.IsBuiltin {
|
||||
return fmt.Errorf("内置角色不可删除")
|
||||
}
|
||||
n, err := s.roles.CountUsers(ctx, rid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("角色仍被用户使用")
|
||||
}
|
||||
if err := s.roles.Delete(ctx, rid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleService) Get(ctx context.Context, tenantID string, rid string) (*entity.Role, error) {
|
||||
r, err := s.roles.GetByID(ctx, rid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("角色不属于当前租户")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *roleService) List(ctx context.Context, tenantID string, name, code string, page, pageSize int) (*RoleListResponse, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
rows, total, err := s.roles.List(ctx, tenantID, name, code, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := int(total) / pageSize
|
||||
if int(total)%pageSize != 0 {
|
||||
tp++
|
||||
}
|
||||
return &RoleListResponse{Items: rows, Total: total, Page: page, PageSize: pageSize, TotalPages: tp}, nil
|
||||
}
|
||||
|
||||
func (s *roleService) AssignMenus(ctx context.Context, tenantID string, roleID string, menuIDs []string, grantorUserID *string) error {
|
||||
r, err := s.roles.GetByID(ctx, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.TenantID != tenantID {
|
||||
return fmt.Errorf("角色不属于当前租户")
|
||||
}
|
||||
if err := s.assertMenuSubset(ctx, grantorUserID, menuIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.roles.ReplaceRoleMenus(ctx, roleID, menuIDs)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package service
|
||||
|
||||
import "giter.top/smart/internal/iam/entity"
|
||||
|
||||
// MergeDataScope 多角色数据范围并集:取最大(PRD:全部 > 本部门及子部门 > 本部门 > 仅本人)
|
||||
func MergeDataScope(scopes []int16) int16 {
|
||||
var m int16
|
||||
for _, s := range scopes {
|
||||
if s > m {
|
||||
m = s
|
||||
}
|
||||
}
|
||||
if m == 0 {
|
||||
return entity.DataScopeSelf
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TenantService 租户
|
||||
type TenantService interface {
|
||||
Create(ctx context.Context, req *CreateTenantRequest) (*entity.Tenant, error)
|
||||
Update(ctx context.Context, id string, req *UpdateTenantRequest) (*entity.Tenant, error)
|
||||
Delete(ctx context.Context, ids []string) error
|
||||
Get(ctx context.Context, id string) (*entity.Tenant, error)
|
||||
List(ctx context.Context, name, code string, status *int16, page, pageSize int) (*TenantListResponse, error)
|
||||
}
|
||||
|
||||
type CreateTenantRequest struct {
|
||||
TenantCode string `json:"tenant_code" binding:"required,max=64"`
|
||||
TenantName string `json:"tenant_name" binding:"required,max=128"`
|
||||
AdminUserName string `json:"admin_user_name" binding:"required,max=64"`
|
||||
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
|
||||
AdminRealName string `json:"admin_real_name" binding:"max=64"`
|
||||
}
|
||||
|
||||
type UpdateTenantRequest struct {
|
||||
TenantName *string `json:"tenant_name"`
|
||||
TenantCode *string `json:"tenant_code" binding:"omitempty,max=64"`
|
||||
Status *int16 `json:"status"`
|
||||
ExpireTime *string `json:"expire_time"` // RFC3339
|
||||
}
|
||||
|
||||
type TenantListItem struct {
|
||||
entity.Tenant
|
||||
UserCount int64 `json:"user_count"`
|
||||
DeptCount int64 `json:"dept_count"`
|
||||
}
|
||||
|
||||
type TenantListResponse struct {
|
||||
Items []TenantListItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
db *gorm.DB
|
||||
tenants repository.TenantRepository
|
||||
depts repository.DeptRepository
|
||||
users repository.UserRepository
|
||||
roles repository.RoleRepository
|
||||
menus repository.MenuRepository
|
||||
}
|
||||
|
||||
func NewTenantService(
|
||||
db *gorm.DB,
|
||||
tenants repository.TenantRepository,
|
||||
depts repository.DeptRepository,
|
||||
users repository.UserRepository,
|
||||
roles repository.RoleRepository,
|
||||
menus repository.MenuRepository,
|
||||
) TenantService {
|
||||
return &tenantService{db: db, tenants: tenants, depts: depts, users: users, roles: roles, menus: menus}
|
||||
}
|
||||
|
||||
func (s *tenantService) Create(ctx context.Context, req *CreateTenantRequest) (*entity.Tenant, error) {
|
||||
ok, err := s.tenants.ExistsCode(ctx, req.TenantCode, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("租户编码已存在")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out *entity.Tenant
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
t := &entity.Tenant{
|
||||
ID: id.New(),
|
||||
TenantCode: req.TenantCode,
|
||||
TenantName: req.TenantName,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var ucount int64
|
||||
if err := tx.Model(&entity.User{}).Where("tenant_id = ? AND user_name = ?", t.ID, req.AdminUserName).Count(&ucount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if ucount > 0 {
|
||||
return fmt.Errorf("管理员账号已存在")
|
||||
}
|
||||
root := &entity.Dept{
|
||||
ID: id.New(),
|
||||
TenantID: t.ID,
|
||||
ParentID: "",
|
||||
DeptName: req.TenantName,
|
||||
SortOrder: 0,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(root).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/%s/", root.ID)
|
||||
if err := tx.Model(&entity.Dept{}).Where("id = ?", root.ID).Update("dept_path", path).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
admin := &entity.User{
|
||||
ID: id.New(),
|
||||
TenantID: t.ID,
|
||||
DeptID: &root.ID,
|
||||
UserName: req.AdminUserName,
|
||||
RealName: req.AdminRealName,
|
||||
PasswordHash: string(hash),
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(admin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&entity.UserDept{ID: id.New(), UserID: admin.ID, DeptID: root.ID, IsPrimary: true}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
role := &entity.Role{
|
||||
ID: id.New(),
|
||||
TenantID: t.ID,
|
||||
RoleCode: DefaultTenantAdminRoleCode,
|
||||
RoleName: "超级管理员",
|
||||
DataScope: entity.DataScopeAll,
|
||||
Description: "租户初始化角色",
|
||||
IsBuiltin: true,
|
||||
Status: 1,
|
||||
}
|
||||
if err := tx.Create(role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var allMenus []entity.Menu
|
||||
if err := tx.Find(&allMenus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range allMenus {
|
||||
if err := tx.Create(&entity.RoleMenu{ID: id.New(), RoleID: role.ID, MenuID: m.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&entity.UserRole{ID: id.New(), UserID: admin.ID, RoleID: role.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
aid := admin.ID
|
||||
if err := tx.Model(t).Update("admin_user_id", aid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
t.AdminUserID = &aid
|
||||
out = t
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *tenantService) Update(ctx context.Context, id string, req *UpdateTenantRequest) (*entity.Tenant, error) {
|
||||
t, err := s.tenants.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("租户不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if req.TenantName != nil && *req.TenantName != "" {
|
||||
t.TenantName = *req.TenantName
|
||||
if root, err := s.depts.FindRoot(ctx, t.ID); err == nil {
|
||||
root.DeptName = *req.TenantName
|
||||
_ = s.depts.Update(ctx, root)
|
||||
}
|
||||
}
|
||||
if req.TenantCode != nil && *req.TenantCode != "" {
|
||||
ok, err := s.tenants.ExistsCode(ctx, *req.TenantCode, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("租户编码已存在")
|
||||
}
|
||||
t.TenantCode = *req.TenantCode
|
||||
}
|
||||
if req.Status != nil {
|
||||
t.Status = *req.Status
|
||||
}
|
||||
if req.ExpireTime != nil && *req.ExpireTime != "" {
|
||||
et, err := time.Parse(time.RFC3339, *req.ExpireTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("到期时间格式无效: %w", err)
|
||||
}
|
||||
t.ExpireTime = &et
|
||||
if et.Before(time.Now()) {
|
||||
t.Status = 0
|
||||
}
|
||||
}
|
||||
if err := s.tenants.Update(ctx, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *tenantService) Delete(ctx context.Context, ids []string) error {
|
||||
for _, tid := range ids {
|
||||
n, err := s.tenants.CountUsers(ctx, tid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("租户 %s 仍存在用户,无法删除", tid)
|
||||
}
|
||||
}
|
||||
for _, tid := range ids {
|
||||
if err := s.db.WithContext(ctx).Delete(&entity.Tenant{}, "id = ?", tid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *tenantService) Get(ctx context.Context, id string) (*entity.Tenant, error) {
|
||||
return s.tenants.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *tenantService) List(ctx context.Context, name, code string, status *int16, page, pageSize int) (*TenantListResponse, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
rows, total, err := s.tenants.List(ctx, name, code, status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]TenantListItem, 0, len(rows))
|
||||
for _, t := range rows {
|
||||
uc, _ := s.tenants.CountUsers(ctx, t.ID)
|
||||
dc, _ := s.tenants.CountDepts(ctx, t.ID)
|
||||
items = append(items, TenantListItem{Tenant: t, UserCount: uc, DeptCount: dc})
|
||||
}
|
||||
tp := int(total) / pageSize
|
||||
if int(total)%pageSize != 0 {
|
||||
tp++
|
||||
}
|
||||
return &TenantListResponse{Items: items, Total: total, Page: page, PageSize: pageSize, TotalPages: tp}, nil
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"giter.top/smart/internal/iam/entity"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserService 用户
|
||||
type UserService interface {
|
||||
Create(ctx context.Context, tenantID string, req *CreateUserRequest) (*entity.User, error)
|
||||
Update(ctx context.Context, tenantID string, uid string, req *UpdateUserRequest) (*entity.User, error)
|
||||
Delete(ctx context.Context, tenantID string, ids []string) error
|
||||
Get(ctx context.Context, tenantID string, uid string) (*entity.User, error)
|
||||
List(ctx context.Context, tenantID string, q *UserListQuery) (*UserListResponse, error)
|
||||
DataScopeForUser(ctx context.Context, userID string) (int16, error)
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
UserName string `json:"user_name" binding:"required,max=64"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
RealName string `json:"real_name" binding:"max=64"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
DeptID *string `json:"dept_id"`
|
||||
DeptIDs []string `json:"dept_ids"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
RealName *string `json:"real_name"`
|
||||
Phone *string `json:"phone"`
|
||||
Email *string `json:"email"`
|
||||
DeptID *string `json:"dept_id"`
|
||||
DeptIDs []string `json:"dept_ids"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
Status *int16 `json:"status"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
|
||||
type UserListQuery struct {
|
||||
DeptID *string
|
||||
RoleID *string
|
||||
Keyword string
|
||||
Status *int16
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type UserListResponse struct {
|
||||
Items []entity.User `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
users repository.UserRepository
|
||||
roles repository.RoleRepository
|
||||
}
|
||||
|
||||
func NewUserService(users repository.UserRepository, roles repository.RoleRepository) UserService {
|
||||
return &userService{users: users, roles: roles}
|
||||
}
|
||||
|
||||
func (s *userService) Create(ctx context.Context, tenantID string, req *CreateUserRequest) (*entity.User, error) {
|
||||
ok, err := s.users.ExistsUserName(ctx, tenantID, req.UserName, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
return nil, fmt.Errorf("账号已存在")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &entity.User{
|
||||
ID: id.New(),
|
||||
TenantID: tenantID,
|
||||
UserName: req.UserName,
|
||||
RealName: req.RealName,
|
||||
Phone: req.Phone,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hash),
|
||||
Status: 1,
|
||||
}
|
||||
if req.DeptID != nil {
|
||||
u.DeptID = req.DeptID
|
||||
}
|
||||
if err := s.users.Create(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
depts := req.DeptIDs
|
||||
primary := ""
|
||||
if req.DeptID != nil {
|
||||
primary = *req.DeptID
|
||||
}
|
||||
if len(depts) == 0 && primary != "" {
|
||||
depts = []string{primary}
|
||||
}
|
||||
if len(depts) > 0 {
|
||||
if primary == "" {
|
||||
primary = depts[0]
|
||||
}
|
||||
u.DeptID = &primary
|
||||
_ = s.users.Update(ctx, u)
|
||||
if err := s.users.ReplaceUserDepts(ctx, u.ID, primary, depts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(req.RoleIDs) > 0 {
|
||||
if err := s.users.ReplaceUserRoles(ctx, u.ID, req.RoleIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *userService) Update(ctx context.Context, tenantID string, uid string, req *UpdateUserRequest) (*entity.User, error) {
|
||||
u, err := s.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if u.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("用户不属于当前租户")
|
||||
}
|
||||
if req.RealName != nil {
|
||||
u.RealName = *req.RealName
|
||||
}
|
||||
if req.Phone != nil {
|
||||
u.Phone = *req.Phone
|
||||
}
|
||||
if req.Email != nil {
|
||||
u.Email = *req.Email
|
||||
}
|
||||
if req.Status != nil {
|
||||
u.Status = *req.Status
|
||||
}
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.PasswordHash = string(hash)
|
||||
}
|
||||
if err := s.users.Update(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.DeptIDs != nil || req.DeptID != nil {
|
||||
depts := req.DeptIDs
|
||||
primary := ""
|
||||
if req.DeptID != nil {
|
||||
primary = *req.DeptID
|
||||
u.DeptID = req.DeptID
|
||||
_ = s.users.Update(ctx, u)
|
||||
}
|
||||
if len(depts) == 0 && primary != "" {
|
||||
depts = []string{primary}
|
||||
}
|
||||
if primary == "" && len(depts) > 0 {
|
||||
primary = depts[0]
|
||||
}
|
||||
if err := s.users.ReplaceUserDepts(ctx, u.ID, primary, depts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.RoleIDs != nil {
|
||||
if err := s.users.ReplaceUserRoles(ctx, u.ID, req.RoleIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *userService) Delete(ctx context.Context, tenantID string, ids []string) error {
|
||||
for _, uid := range ids {
|
||||
u, err := s.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.TenantID != tenantID {
|
||||
return fmt.Errorf("用户 %s 不属于当前租户", uid)
|
||||
}
|
||||
if err := s.users.Delete(ctx, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userService) Get(ctx context.Context, tenantID string, uid string) (*entity.User, error) {
|
||||
u, err := s.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.TenantID != tenantID {
|
||||
return nil, fmt.Errorf("用户不属于当前租户")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *userService) List(ctx context.Context, tenantID string, q *UserListQuery) (*UserListResponse, error) {
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 10
|
||||
}
|
||||
rows, total, err := s.users.List(ctx, tenantID, q.DeptID, q.RoleID, q.Keyword, q.Status, q.Page, q.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := int(total) / q.PageSize
|
||||
if int(total)%q.PageSize != 0 {
|
||||
tp++
|
||||
}
|
||||
return &UserListResponse{Items: rows, Total: total, Page: q.Page, PageSize: q.PageSize, TotalPages: tp}, nil
|
||||
}
|
||||
|
||||
func (s *userService) DataScopeForUser(ctx context.Context, userID string) (int16, error) {
|
||||
roles, err := s.roles.ListRolesByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
scopes := make([]int16, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
scopes = append(scopes, r.DataScope)
|
||||
}
|
||||
return MergeDataScope(scopes), nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/iam/handler"
|
||||
"giter.top/smart/internal/iam/repository"
|
||||
"giter.top/smart/internal/iam/service"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// HandlerProviderSet 处理程序提供者集合
|
||||
var handlerProviderSet = wire.NewSet(
|
||||
handler.NewTenantHandler,
|
||||
handler.NewDeptHandler,
|
||||
handler.NewRoleHandler,
|
||||
handler.NewUserHandler,
|
||||
handler.NewMenuHandler,
|
||||
)
|
||||
|
||||
|
||||
// ServiceProviderSet 服务提供者集合
|
||||
var serviceProviderSet = wire.NewSet(
|
||||
service.NewTenantService,
|
||||
service.NewDeptService,
|
||||
service.NewRoleService,
|
||||
service.NewUserService,
|
||||
service.NewMenuService,
|
||||
)
|
||||
|
||||
|
||||
// RepositoryProviderSet 仓库提供者集合
|
||||
var repositoryProviderSet = wire.NewSet(
|
||||
repository.NewTenantRepository,
|
||||
repository.NewDeptRepository,
|
||||
repository.NewRoleRepository,
|
||||
repository.NewUserRepository,
|
||||
repository.NewMenuRepository,
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
handlerProviderSet,
|
||||
serviceProviderSet,
|
||||
repositoryProviderSet,
|
||||
// 路由注册
|
||||
NewIamRoutes,
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// corsLocalDev 允许本机前端(localhost / 127.0.0.1 任意端口)跨域访问 API 与 OAuth;生产同域部署时可关闭或改为配置白名单。
|
||||
func corsLocalDev() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
o := c.GetHeader("Origin")
|
||||
if o != "" && isLocalDevOrigin(o) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", o)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Tenant-ID, X-User-ID, X-Grantor-User-ID")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isLocalDevOrigin(o string) bool {
|
||||
return strings.HasPrefix(o, "http://localhost:") ||
|
||||
strings.HasPrefix(o, "http://127.0.0.1:") ||
|
||||
strings.HasPrefix(o, "http://[::1]:")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"giter.top/smart/pkg/config"
|
||||
)
|
||||
|
||||
type GrpcServer struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewGrpcServer(cfg *config.Config) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
addr: cfg.Server.Grpc.Addr,
|
||||
timeout: cfg.Server.Grpc.Timeout,
|
||||
}
|
||||
}
|
||||
func (s *GrpcServer) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GrpcServer) Stop() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"giter.top/smart/internal/auth"
|
||||
"giter.top/smart/internal/iam"
|
||||
"giter.top/smart/internal/system"
|
||||
"giter.top/smart/pkg/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type HttpServer struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
engine *gin.Engine
|
||||
}
|
||||
|
||||
func NewHttpServer(cfg *config.Config,
|
||||
engine *gin.Engine,
|
||||
) *HttpServer {
|
||||
return &HttpServer{
|
||||
addr: cfg.Server.Http.Addr,
|
||||
timeout: cfg.Server.Http.Timeout,
|
||||
engine: engine,
|
||||
}
|
||||
}
|
||||
func (s *HttpServer) Run() error {
|
||||
s.engine.Run(s.addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HttpServer) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
type HttpRoutes interface {
|
||||
Register(engine *gin.Engine , apiGroup *gin.RouterGroup)
|
||||
}
|
||||
|
||||
func NewHttpEngine(cfg *config.Config,httpRoutes []HttpRoutes) *gin.Engine {
|
||||
engine := gin.Default()
|
||||
engine.Use(corsLocalDev())
|
||||
// 健康检查端点,供负载均衡或编排探活。
|
||||
engine.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
// 处理注册的路由
|
||||
apiGroup := engine.Group("/api/v1")
|
||||
for _, r := range httpRoutes {
|
||||
r.Register(engine, apiGroup)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func NewHttpRouteRegistrars(
|
||||
authRoutes *auth.AuthRoutes,
|
||||
systemRoutes *system.SystemRoutes,
|
||||
iamRoutes *iam.IamRoutes,
|
||||
) []HttpRoutes {
|
||||
return []HttpRoutes{
|
||||
authRoutes,
|
||||
systemRoutes,
|
||||
iamRoutes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"giter.top/smart/pkg/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewHttpEngine,
|
||||
ProvideServers,
|
||||
NewHttpRouteRegistrars,
|
||||
)
|
||||
|
||||
type Server interface {
|
||||
Run() error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
func ProvideServers(cfg *config.Config, engine *gin.Engine) []Server {
|
||||
return []Server{
|
||||
NewHttpServer(cfg, engine),
|
||||
NewGrpcServer(cfg),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SystemParam 系统参数实体
|
||||
// 用于存储系统运行所需的各种配置参数,支持多种数据类型和分组管理
|
||||
type SystemParam struct {
|
||||
// ID 主键,使用 UUID v4 保证全局唯一性,避免自增 ID 带来的信息泄露风险
|
||||
ID string `json:"id" gorm:"column:id;type:varchar(36);primaryKey;not null;comment:主键"`
|
||||
|
||||
// ParamKey 参数键名,全局唯一,用于标识和访问参数值
|
||||
// 命名规范:小写字母 + 下划线,如:site_name, max_upload_size
|
||||
ParamKey string `json:"param_key" gorm:"column:param_key;type:varchar(100);uniqueIndex;not null;comment:参数键"`
|
||||
|
||||
// ParamValue 参数值,存储实际配置内容
|
||||
// 根据 ParamType 不同,可能是字符串、数字、布尔值或 JSON 数组
|
||||
ParamValue string `json:"param_value" gorm:"column:param_value;type:varchar(1000);not null;comment:参数值"`
|
||||
|
||||
// ParamType 参数类型,决定参数的校验规则和展示方式
|
||||
// 可选值:text(文本), number(数字), boolean(布尔), select(下拉选择)
|
||||
ParamType string `json:"param_type" gorm:"column:param_type;type:varchar(20);not null;default:'text';comment:类型:text,number,boolean,select"`
|
||||
|
||||
// ParamGroup 参数分组,用于对参数进行逻辑分组管理
|
||||
// 常见分组:basic(基础), security(安全), business(业务), system(系统)
|
||||
ParamGroup string `json:"param_group" gorm:"column:param_group;type:varchar(50);not null;default:'default';comment:分组"`
|
||||
|
||||
// ParamDesc 参数描述,说明该参数的用途、取值范围、默认值等信息
|
||||
// 建议包含:参数说明、可选值说明、修改影响等
|
||||
ParamDesc string `json:"param_desc" gorm:"column:param_desc;type:varchar(500);comment:描述"`
|
||||
|
||||
// CreatorID 创建人 ID,记录创建该参数的用户标识
|
||||
// 用于审计追踪,定位参数创建者
|
||||
CreatorID string `json:"creator_id" gorm:"column:creator_id;type:varchar(36);not null;default:'';comment:创建人 ID"`
|
||||
|
||||
// CreateTime 创建时间,记录参数创建的时间点
|
||||
// 使用指针类型,可以区分"未设置"和"已设置"状态
|
||||
// 数据库层面使用 CURRENT_TIMESTAMP 自动填充
|
||||
CreateTime *time.Time `json:"create_time" gorm:"column:create_time;type:datetime;default:current_timestamp;comment:创建时间"`
|
||||
|
||||
// LastUpdaterID 最后更新人 ID,记录最后一次修改该参数的用户标识
|
||||
// 用于审计追踪,定位参数修改者
|
||||
LastUpdaterID string `json:"last_updater_id" gorm:"column:last_updater_id;type:varchar(36);not null;default:'';comment:最后更新人 ID"`
|
||||
|
||||
// UpdateTime 最后更新时间,记录参数最后一次修改的时间点
|
||||
// 使用指针类型,可以区分"未设置"和"已设置"状态
|
||||
// 数据库层面使用 ON UPDATE CURRENT_TIMESTAMP 自动更新
|
||||
UpdateTime *time.Time `json:"update_time" gorm:"column:update_time;type:datetime;default:current_timestamp;on update current_timestamp;comment:最后更新时间"`
|
||||
}
|
||||
|
||||
// TableName 指定表名为 system_param
|
||||
// 遵循数据库命名规范:小写字母 + 下划线,复数形式
|
||||
func (SystemParam) TableName() string {
|
||||
return "system_param"
|
||||
}
|
||||
|
||||
// ParamType 参数类型常量
|
||||
// 定义系统支持的参数类型,用于前端展示和后端校验
|
||||
type ParamType string
|
||||
|
||||
const (
|
||||
// ParamTypeText 文本类型,适用于字符串值
|
||||
ParamTypeText ParamType = "text"
|
||||
// ParamTypeNumber 数字类型,适用于整数值
|
||||
ParamTypeNumber ParamType = "number"
|
||||
// ParamTypeBoolean 布尔类型,适用于 true/false 值
|
||||
ParamTypeBoolean ParamType = "boolean"
|
||||
// ParamTypeSelect 下拉选择类型,适用于预定义选项值
|
||||
ParamTypeSelect ParamType = "select"
|
||||
)
|
||||
|
||||
// ParamGroup 参数分组常量
|
||||
// 定义系统参数的逻辑分组,便于分类管理和权限控制
|
||||
type ParamGroup string
|
||||
|
||||
const (
|
||||
// GroupBasic 基础配置分组,包含系统基本信息
|
||||
// 如:站点名称、Logo、联系方式等
|
||||
GroupBasic ParamGroup = "basic"
|
||||
|
||||
// GroupSecurity 安全配置分组,包含安全相关参数
|
||||
// 如:密码策略、登录限制、Token 有效期等
|
||||
GroupSecurity ParamGroup = "security"
|
||||
|
||||
// GroupBusiness 业务配置分组,包含业务逻辑相关参数
|
||||
// 如:订单配置、支付参数、业务开关等
|
||||
GroupBusiness ParamGroup = "business"
|
||||
|
||||
// GroupSystem 系统配置分组,包含系统运行参数
|
||||
// 如:缓存配置、日志级别、性能参数等
|
||||
GroupSystem ParamGroup = "system"
|
||||
|
||||
// GroupDefault 默认分组,未明确分组的参数归入此类
|
||||
GroupDefault ParamGroup = "default"
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"giter.top/smart/internal/system/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ParamHandler 系统参数 HTTP 处理器
|
||||
type ParamHandler struct {
|
||||
service service.ParamService
|
||||
}
|
||||
|
||||
// NewParamHandler 创建参数处理器实例
|
||||
func NewParamHandler(svc service.ParamService) *ParamHandler {
|
||||
return &ParamHandler{service: svc}
|
||||
}
|
||||
|
||||
// CreateParam 创建系统参数
|
||||
// @Summary 创建系统参数
|
||||
// @Tags 系统参数
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body service.CreateParamRequest true "创建参数请求"
|
||||
// @Success 201 {object} entity.SystemParam
|
||||
// @Router /api/v1/system/params [post]
|
||||
func (h *ParamHandler) CreateParam(c *gin.Context) {
|
||||
var req service.CreateParamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 从上下文获取用户 ID(实际项目中从 JWT token 解析)
|
||||
creatorID := "system"
|
||||
param, err := h.service.CreateParam(c.Request.Context(), &req, creatorID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, param)
|
||||
}
|
||||
|
||||
// UpdateParam 更新系统参数
|
||||
// @Summary 更新系统参数
|
||||
// @Tags 系统参数
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "参数 ID"
|
||||
// @Param request body service.UpdateParamRequest true "更新参数请求"
|
||||
// @Success 200 {object} entity.SystemParam
|
||||
// @Router /api/v1/system/params/{id} [put]
|
||||
func (h *ParamHandler) UpdateParam(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.UpdateParamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 从上下文获取用户 ID
|
||||
lastUpdaterID := "system"
|
||||
param, err := h.service.UpdateParam(c.Request.Context(), id, &req, lastUpdaterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, param)
|
||||
}
|
||||
|
||||
// DeleteParams 批量删除系统参数
|
||||
// @Summary 批量删除系统参数
|
||||
// @Tags 系统参数
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body []string true "参数 ID 列表"
|
||||
// @Success 204
|
||||
// @Router /api/v1/system/params/batch [delete]
|
||||
func (h *ParamHandler) DeleteParams(c *gin.Context) {
|
||||
var ids []string
|
||||
if err := c.ShouldBindJSON(&ids); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteParams(c.Request.Context(), ids); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetParam 获取单个系统参数
|
||||
// @Summary 获取单个系统参数
|
||||
// @Tags 系统参数
|
||||
// @Produce json
|
||||
// @Param id path string true "参数 ID"
|
||||
// @Success 200 {object} entity.SystemParam
|
||||
// @Router /api/v1/system/params/{id} [get]
|
||||
func (h *ParamHandler) GetParam(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
param, err := h.service.GetParam(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, param)
|
||||
}
|
||||
|
||||
// GetParamByKey 根据键获取系统参数
|
||||
// @Summary 根据键获取系统参数
|
||||
// @Tags 系统参数
|
||||
// @Produce json
|
||||
// @Param key path string true "参数键"
|
||||
// @Success 200 {object} entity.SystemParam
|
||||
// @Router /api/v1/system/params/key/{key} [get]
|
||||
func (h *ParamHandler) GetParamByKey(c *gin.Context) {
|
||||
key := c.Param("key")
|
||||
param, err := h.service.GetParamByKey(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, param)
|
||||
}
|
||||
|
||||
// ListParams 获取系统参数列表
|
||||
// @Summary 获取系统参数列表
|
||||
// @Tags 系统参数
|
||||
// @Produce json
|
||||
// @Param group query string false "分组"
|
||||
// @Param param_key query string false "参数键(模糊搜索)"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Success 200 {object} service.ParamListResponse
|
||||
// @Router /api/v1/system/params [get]
|
||||
func (h *ParamHandler) ListParams(c *gin.Context) {
|
||||
group := c.Query("group")
|
||||
paramKey := c.Query("param_key")
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
|
||||
response, err := h.service.ListParams(c.Request.Context(), group, paramKey, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllParams 获取所有系统参数
|
||||
// @Summary 获取所有系统参数
|
||||
// @Tags 系统参数
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]entity.SystemParam
|
||||
// @Router /api/v1/system/params/all [get]
|
||||
func (h *ParamHandler) GetAllParams(c *gin.Context) {
|
||||
params, err := h.service.GetAllParams(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, params)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/system/handler"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SystemRoutes 注册 system 模块的 HTTP 路由。
|
||||
type SystemRoutes struct {
|
||||
paramHandler *handler.ParamHandler
|
||||
}
|
||||
// NewSystemRoutes 构造 system 模块的路由注册器,由 Wire 注入。
|
||||
func NewSystemRoutes( paramHandler *handler.ParamHandler) *SystemRoutes {
|
||||
return &SystemRoutes{
|
||||
paramHandler: paramHandler,
|
||||
}
|
||||
}
|
||||
// TODO 添加注册信息
|
||||
func (s *SystemRoutes) Register(engine *gin.Engine, apiGroup *gin.RouterGroup) {
|
||||
group := apiGroup.Group("/system")
|
||||
s.registerParamRoutes(group)
|
||||
}
|
||||
// 系统参数路由
|
||||
func (s *SystemRoutes) registerParamRoutes(group *gin.RouterGroup) {
|
||||
paramGroup := group.Group("/param")
|
||||
{
|
||||
paramGroup.POST("/create", s.paramHandler.CreateParam)
|
||||
paramGroup.PUT("/update", s.paramHandler.UpdateParam)
|
||||
paramGroup.DELETE("/delete-batch", s.paramHandler.DeleteParams)
|
||||
paramGroup.GET("/get", s.paramHandler.GetParam)
|
||||
paramGroup.GET("/list", s.paramHandler.ListParams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"giter.top/smart/internal/system/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrNotFound 记录未找到
|
||||
var ErrNotFound = errors.New("param not found")
|
||||
|
||||
// ParamRepository 系统参数数据访问层
|
||||
type ParamRepository interface {
|
||||
// Create 创建系统参数
|
||||
Create(ctx context.Context, param *entity.SystemParam) error
|
||||
// Update 更新系统参数
|
||||
Update(ctx context.Context, param *entity.SystemParam) error
|
||||
// Delete 删除系统参数
|
||||
Delete(ctx context.Context, id string) error
|
||||
// DeleteBatch 批量删除
|
||||
DeleteBatch(ctx context.Context, ids []string) error
|
||||
// GetByID 根据 ID 获取
|
||||
GetByID(ctx context.Context, id string) (*entity.SystemParam, error)
|
||||
// GetByKey 根据键获取
|
||||
GetByKey(ctx context.Context, key string) (*entity.SystemParam, error)
|
||||
// List 获取列表(支持分页和筛选)
|
||||
List(ctx context.Context, group string, paramKey string, page, pageSize int) ([]entity.SystemParam, int64, error)
|
||||
// GetAll 获取所有参数(用于缓存)
|
||||
GetAll(ctx context.Context) (map[string]entity.SystemParam, error)
|
||||
// ExistsByKey 检查键是否存在(排除指定 ID)
|
||||
ExistsByKey(ctx context.Context, key string, excludeID string) (bool, error)
|
||||
}
|
||||
|
||||
type paramRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewParamRepository 创建参数仓库实例
|
||||
func NewParamRepository(db *gorm.DB) ParamRepository {
|
||||
return ¶mRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *paramRepository) Create(ctx context.Context, param *entity.SystemParam) error {
|
||||
return r.db.WithContext(ctx).Create(param).Error
|
||||
}
|
||||
|
||||
func (r *paramRepository) Update(ctx context.Context, param *entity.SystemParam) error {
|
||||
return r.db.WithContext(ctx).Save(param).Error
|
||||
}
|
||||
|
||||
func (r *paramRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&entity.SystemParam{}).Error
|
||||
}
|
||||
|
||||
func (r *paramRepository) DeleteBatch(ctx context.Context, ids []string) error {
|
||||
return r.db.WithContext(ctx).Where("id IN ?", ids).Delete(&entity.SystemParam{}).Error
|
||||
}
|
||||
|
||||
func (r *paramRepository) GetByID(ctx context.Context, id string) (*entity.SystemParam, error) {
|
||||
var param entity.SystemParam
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(¶m).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¶m, nil
|
||||
}
|
||||
|
||||
func (r *paramRepository) GetByKey(ctx context.Context, key string) (*entity.SystemParam, error) {
|
||||
var param entity.SystemParam
|
||||
err := r.db.WithContext(ctx).Where("param_key = ?", key).First(¶m).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ¶m, nil
|
||||
}
|
||||
|
||||
func (r *paramRepository) List(ctx context.Context, group string, paramKey string, page, pageSize int) ([]entity.SystemParam, int64, error) {
|
||||
var params []entity.SystemParam
|
||||
var total int64
|
||||
query := r.db.WithContext(ctx).Model(&entity.SystemParam{})
|
||||
|
||||
// 应用筛选条件
|
||||
if group != "" {
|
||||
query = query.Where("param_group = ?", group)
|
||||
}
|
||||
if paramKey != "" {
|
||||
query = query.Where("param_key LIKE ?", "%"+paramKey+"%")
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(¶ms).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return params, total, nil
|
||||
}
|
||||
|
||||
func (r *paramRepository) GetAll(ctx context.Context) (map[string]entity.SystemParam, error) {
|
||||
var params []entity.SystemParam
|
||||
err := r.db.WithContext(ctx).Find(¶ms).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]entity.SystemParam, len(params))
|
||||
for _, param := range params {
|
||||
result[param.ParamKey] = param
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *paramRepository) ExistsByKey(ctx context.Context, key string, excludeID string) (bool, error) {
|
||||
query := r.db.WithContext(ctx).Where("param_key = ?", key)
|
||||
if excludeID != "" {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
var count int64
|
||||
err := query.Model(&entity.SystemParam{}).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"giter.top/smart/internal/system/entity"
|
||||
"giter.top/smart/internal/system/repository"
|
||||
"giter.top/smart/pkg/utils/id"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ErrInvalidParam 参数无效
|
||||
var ErrInvalidParam = errors.New("invalid param")
|
||||
|
||||
// ParamService 系统参数业务逻辑层
|
||||
type ParamService interface {
|
||||
// CreateParam 创建系统参数
|
||||
CreateParam(ctx context.Context, req *CreateParamRequest, creatorID string) (*entity.SystemParam, error)
|
||||
// UpdateParam 更新系统参数
|
||||
UpdateParam(ctx context.Context, id string, req *UpdateParamRequest, lastUpdaterID string) (*entity.SystemParam, error)
|
||||
// DeleteParam 删除系统参数
|
||||
DeleteParam(ctx context.Context, id string) error
|
||||
// DeleteParams 批量删除
|
||||
DeleteParams(ctx context.Context, ids []string) error
|
||||
// GetParam 获取单个参数
|
||||
GetParam(ctx context.Context, id string) (*entity.SystemParam, error)
|
||||
// GetParamByKey 根据键获取参数
|
||||
GetParamByKey(ctx context.Context, key string) (*entity.SystemParam, error)
|
||||
// ListParams 获取参数列表
|
||||
ListParams(ctx context.Context, group string, paramKey string, page, pageSize int) (*ParamListResponse, error)
|
||||
// GetAllParams 获取所有参数(用于缓存)
|
||||
GetAllParams(ctx context.Context) (map[string]entity.SystemParam, error)
|
||||
// GetParamValue 获取参数值(便捷方法)
|
||||
GetParamValue(ctx context.Context, key string) (string, error)
|
||||
// GetParamValueWithDefault 获取参数值,不存在则返回默认值
|
||||
GetParamValueWithDefault(ctx context.Context, key string, defaultValue string) string
|
||||
}
|
||||
|
||||
// CreateParamRequest 创建参数请求
|
||||
type CreateParamRequest struct {
|
||||
ParamKey string `json:"param_key" binding:"required,max=100"`
|
||||
ParamValue string `json:"param_value" binding:"required"`
|
||||
ParamType string `json:"param_type" binding:"required,oneof=text number boolean select"`
|
||||
ParamGroup string `json:"param_group" binding:"required,max:50"`
|
||||
ParamDesc string `json:"param_desc" max:"500"`
|
||||
}
|
||||
|
||||
// UpdateParamRequest 更新参数请求
|
||||
type UpdateParamRequest struct {
|
||||
ParamValue string `json:"param_value"`
|
||||
ParamType string `json:"param_type" binding:"omitempty,oneof=text number boolean select"`
|
||||
ParamDesc string `json:"param_desc" max:"500"`
|
||||
}
|
||||
|
||||
// ParamListResponse 参数列表响应
|
||||
type ParamListResponse struct {
|
||||
Items []entity.SystemParam `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type paramService struct {
|
||||
repo repository.ParamRepository
|
||||
cache redis.UniversalClient
|
||||
cacheKey string
|
||||
}
|
||||
|
||||
// NewParamService 创建参数服务实例(与 cache.NewRedisClient 返回的 redis.UniversalClient 一致,便于 Wire 注入)
|
||||
func NewParamService(repo repository.ParamRepository, cacheClient redis.UniversalClient) ParamService {
|
||||
return ¶mService{
|
||||
repo: repo,
|
||||
cache: cacheClient,
|
||||
cacheKey: "system:params:*",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *paramService) CreateParam(ctx context.Context, req *CreateParamRequest, creatorID string) (*entity.SystemParam, error) {
|
||||
// 生成唯一 ID (UUID v7)
|
||||
id := id.New()
|
||||
|
||||
// 检查键是否已存在
|
||||
exists, err := s.repo.ExistsByKey(ctx, req.ParamKey, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("检查参数键失败:%w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("参数键 %s 已存在", req.ParamKey)
|
||||
}
|
||||
|
||||
param := &entity.SystemParam{
|
||||
ID: id,
|
||||
ParamKey: req.ParamKey,
|
||||
ParamValue: req.ParamValue,
|
||||
ParamType: req.ParamType,
|
||||
ParamGroup: req.ParamGroup,
|
||||
ParamDesc: req.ParamDesc,
|
||||
CreatorID: creatorID,
|
||||
LastUpdaterID: creatorID,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, param); err != nil {
|
||||
return nil, fmt.Errorf("创建参数失败:%w", err)
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.refreshCache(ctx)
|
||||
return param, nil
|
||||
}
|
||||
|
||||
func (s *paramService) UpdateParam(ctx context.Context, id string, req *UpdateParamRequest, lastUpdaterID string) (*entity.SystemParam, error) {
|
||||
// 获取现有参数
|
||||
param, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("参数不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("获取参数失败:%w", err)
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.ParamValue != "" {
|
||||
param.ParamValue = req.ParamValue
|
||||
}
|
||||
if req.ParamType != "" {
|
||||
param.ParamType = req.ParamType
|
||||
}
|
||||
if req.ParamDesc != "" {
|
||||
param.ParamDesc = req.ParamDesc
|
||||
}
|
||||
|
||||
param.LastUpdaterID = lastUpdaterID
|
||||
if err := s.repo.Update(ctx, param); err != nil {
|
||||
return nil, fmt.Errorf("更新参数失败:%w", err)
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.refreshCache(ctx)
|
||||
return param, nil
|
||||
}
|
||||
|
||||
func (s *paramService) DeleteParam(ctx context.Context, id string) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("删除参数失败:%w", err)
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.refreshCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *paramService) DeleteParams(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.DeleteBatch(ctx, ids); err != nil {
|
||||
return fmt.Errorf("批量删除参数失败:%w", err)
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.refreshCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *paramService) GetParam(ctx context.Context, id string) (*entity.SystemParam, error) {
|
||||
param, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("参数不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return param, nil
|
||||
}
|
||||
|
||||
func (s *paramService) GetParamByKey(ctx context.Context, key string) (*entity.SystemParam, error) {
|
||||
param, err := s.repo.GetByKey(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, fmt.Errorf("参数 %s 不存在", key)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return param, nil
|
||||
}
|
||||
|
||||
func (s *paramService) ListParams(ctx context.Context, group string, paramKey string, page, pageSize int) (*ParamListResponse, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
items, total, err := s.repo.List(ctx, group, paramKey, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取参数列表失败:%w", err)
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize != 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &ParamListResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *paramService) GetAllParams(ctx context.Context) (map[string]entity.SystemParam, error) {
|
||||
// 先从缓存获取
|
||||
if s.cache != nil {
|
||||
cached := s.cache.Get(ctx, "system:params:all").Val()
|
||||
if cached != "" {
|
||||
var params map[string]entity.SystemParam
|
||||
if err := json.Unmarshal([]byte(cached), ¶ms); err == nil {
|
||||
return params, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("解析缓存数据失败:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库获取
|
||||
params, err := s.repo.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
if s.cache != nil {
|
||||
data, _ := json.Marshal(params)
|
||||
s.cache.Set(ctx, "system:params:all", string(data), 0) // 0 表示永不过期
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func (s *paramService) GetParamValue(ctx context.Context, key string) (string, error) {
|
||||
param, err := s.GetParamByKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return param.ParamValue, nil
|
||||
}
|
||||
|
||||
func (s *paramService) GetParamValueWithDefault(ctx context.Context, key string, defaultValue string) string {
|
||||
value, err := s.GetParamValue(ctx, key)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// refreshCache 刷新缓存
|
||||
func (s *paramService) refreshCache(ctx context.Context) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 删除缓存,让下次请求重新构建
|
||||
s.cache.Del(ctx, "system:params:all")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"giter.top/smart/internal/system/handler"
|
||||
"giter.top/smart/internal/system/repository"
|
||||
"giter.top/smart/internal/system/service"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// HandlerProviderSet 处理程序提供者集合
|
||||
var handlerProviderSet = wire.NewSet(
|
||||
handler.NewParamHandler,
|
||||
)
|
||||
|
||||
|
||||
// ServiceProviderSet 服务提供者集合
|
||||
var serviceProviderSet = wire.NewSet(
|
||||
service.NewParamService,
|
||||
)
|
||||
|
||||
|
||||
// RepositoryProviderSet 仓库提供者集合
|
||||
var repositoryProviderSet = wire.NewSet(
|
||||
repository.NewParamRepository,
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
handlerProviderSet,
|
||||
serviceProviderSet,
|
||||
repositoryProviderSet,
|
||||
NewSystemRoutes,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
-- IAM 表结构(与 internal/iam/entity 中 GORM 模型一致;PostgreSQL)
|
||||
-- 执行:psql $DATABASE_URL -f migrations/postgres/001_iam.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 租户
|
||||
CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
tenant_code varchar(64) NOT NULL,
|
||||
tenant_name varchar(128) NOT NULL,
|
||||
admin_user_id varchar(36) NULL,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
expire_time timestamptz NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_tenant_code ON iam_tenant (tenant_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_iam_tenant_deleted_at ON iam_tenant (deleted_at);
|
||||
|
||||
-- 部门(根部门 parent_id 为空串)
|
||||
CREATE TABLE IF NOT EXISTS iam_dept (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
parent_id varchar(36) NOT NULL DEFAULT '',
|
||||
dept_name varchar(128) NOT NULL,
|
||||
dept_path text NULL,
|
||||
leader_id varchar(36) NULL,
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_tenant ON iam_dept (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dept_parent ON iam_dept (parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iam_dept_deleted_at ON iam_dept (deleted_at);
|
||||
|
||||
-- 用户
|
||||
CREATE TABLE IF NOT EXISTS iam_user (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
dept_id varchar(36) NULL,
|
||||
user_name varchar(64) NOT NULL,
|
||||
real_name varchar(64) NULL,
|
||||
password_hash varchar(255) NOT NULL,
|
||||
phone varchar(20) NULL,
|
||||
email varchar(128) NULL,
|
||||
avatar varchar(512) NULL,
|
||||
gender smallint NOT NULL DEFAULT 0,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
login_attempts int NOT NULL DEFAULT 0,
|
||||
locked_until timestamptz NULL,
|
||||
last_login_at timestamptz NULL,
|
||||
last_login_ip varchar(45) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_tenant ON iam_user (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_dept ON iam_user (dept_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iam_user_deleted_at ON iam_user (deleted_at);
|
||||
|
||||
-- 用户-部门关联
|
||||
CREATE TABLE IF NOT EXISTS iam_user_dept (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
user_id varchar(36) NOT NULL,
|
||||
dept_id varchar(36) NOT NULL,
|
||||
is_primary boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uk_user_dept UNIQUE (user_id, dept_id)
|
||||
);
|
||||
|
||||
-- 角色
|
||||
CREATE TABLE IF NOT EXISTS iam_role (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
role_code varchar(64) NOT NULL,
|
||||
role_name varchar(128) NOT NULL,
|
||||
data_scope smallint NOT NULL DEFAULT 4,
|
||||
description varchar(512) NULL,
|
||||
is_builtin boolean NOT NULL DEFAULT false,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_tenant ON iam_role (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iam_role_deleted_at ON iam_role (deleted_at);
|
||||
|
||||
-- 菜单(全局)
|
||||
CREATE TABLE IF NOT EXISTS iam_menu (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
parent_id varchar(36) NOT NULL DEFAULT '',
|
||||
menu_name varchar(128) NOT NULL,
|
||||
menu_type smallint NOT NULL,
|
||||
perms varchar(128) NULL,
|
||||
path varchar(255) NULL,
|
||||
component varchar(255) NULL,
|
||||
icon varchar(64) NULL,
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
is_visible boolean NOT NULL DEFAULT true,
|
||||
is_builtin boolean NOT NULL DEFAULT false,
|
||||
external_link varchar(512) NULL,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_iam_menu_perms ON iam_menu (perms);
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_parent ON iam_menu (parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iam_menu_deleted_at ON iam_menu (deleted_at);
|
||||
|
||||
-- 用户-角色
|
||||
CREATE TABLE IF NOT EXISTS iam_user_role (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
user_id varchar(36) NOT NULL,
|
||||
role_id varchar(36) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uk_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
|
||||
-- 角色-菜单
|
||||
CREATE TABLE IF NOT EXISTS iam_role_menu (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
role_id varchar(36) NOT NULL,
|
||||
menu_id varchar(36) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uk_role_menu UNIQUE (role_id, menu_id)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 系统参数表(与 internal/system/entity/param_entity.go 中 GORM 模型一致;PostgreSQL)
|
||||
-- 执行:psql $DATABASE_URL -f migrations/postgres/002_system.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_param (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
param_key varchar(100) NOT NULL,
|
||||
param_value varchar(1000) NOT NULL,
|
||||
param_type varchar(20) NOT NULL DEFAULT 'text',
|
||||
param_group varchar(50) NOT NULL DEFAULT 'default',
|
||||
param_desc varchar(500) NULL,
|
||||
creator_id varchar(36) NOT NULL DEFAULT '',
|
||||
create_time timestamptz NULL DEFAULT now(),
|
||||
last_updater_id varchar(36) NOT NULL DEFAULT '',
|
||||
update_time timestamptz NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_system_param_key ON system_param (param_key);
|
||||
|
||||
COMMENT ON TABLE system_param IS '系统运行参数(键值)';
|
||||
COMMENT ON COLUMN system_param.param_type IS 'text,number,boolean,select';
|
||||
COMMENT ON COLUMN system_param.param_group IS 'basic,security,business,system,default 等';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 可选:平台租户初始化(与 entity.PlatformTenantID / TenantCode 约定一致)
|
||||
-- 需在 001_iam.sql 执行成功后运行
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO iam_tenant (id, tenant_code, tenant_name, status, created_at, updated_at)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'platform',
|
||||
'平台',
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (tenant_code) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,122 @@
|
||||
-- 平台租户内置角色 + 平台管理员账号(与 internal/iam/service 中租户初始化逻辑对齐)
|
||||
-- 依赖:已执行 001_iam.sql、002_system.sql、003_seed_platform_tenant.sql
|
||||
--
|
||||
-- 默认管理员(平台租户 platform):
|
||||
-- 用户名:admin
|
||||
-- 密码:Admin@123 (bcrypt DefaultCost,与 golang.org/x/crypto/bcrypt 一致)
|
||||
--
|
||||
-- 内置角色:
|
||||
-- tenant_admin — 与 DefaultTenantAdminRoleCode 一致,data_scope=4(全部)
|
||||
-- user — 普通用户占位,data_scope=1(本人)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 固定 UUID,便于排查与文档引用
|
||||
-- platform_tenant_id = 00000000-0000-0000-0000-000000000001
|
||||
-- root_dept_id = 20000000-0000-4000-8000-000000000001
|
||||
-- role_admin_id = 20000000-0000-4000-8000-000000000002
|
||||
-- role_user_id = 20000000-0000-4000-8000-000000000006
|
||||
-- admin_user_id = 20000000-0000-4000-8000-000000000003
|
||||
|
||||
-- 1) 平台根部门(与 TenantService.Create 中根部门一致)
|
||||
INSERT INTO iam_dept (
|
||||
id, tenant_id, parent_id, dept_name, dept_path, sort_order, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'20000000-0000-4000-8000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'',
|
||||
'平台',
|
||||
'/20000000-0000-4000-8000-000000000001/',
|
||||
0,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 2) 内置角色:租户超级管理员(与新租户初始化角色编码一致)
|
||||
INSERT INTO iam_role (
|
||||
id, tenant_id, role_code, role_name, data_scope, description, is_builtin, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'20000000-0000-4000-8000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'tenant_admin',
|
||||
'超级管理员',
|
||||
4,
|
||||
'内置:租户内全部数据权限(与 DefaultTenantAdminRoleCode 一致)',
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 3) 内置角色:普通用户(占位)
|
||||
INSERT INTO iam_role (
|
||||
id, tenant_id, role_code, role_name, data_scope, description, is_builtin, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'20000000-0000-4000-8000-000000000006',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'user',
|
||||
'普通用户',
|
||||
1,
|
||||
'内置:本人数据范围(DataScopeSelf)',
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 4) 平台管理员用户(密码 Admin@123)
|
||||
INSERT INTO iam_user (
|
||||
id, tenant_id, dept_id, user_name, real_name, password_hash, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'20000000-0000-4000-8000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'20000000-0000-4000-8000-000000000001',
|
||||
'admin',
|
||||
'平台管理员',
|
||||
'$2a$10$8p7lXpy9mr7hhnAiOA8pNOgAU128xIWFxrU90iqw.F4VSw77vDEYO',
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 5) 用户-部门(主部门)
|
||||
INSERT INTO iam_user_dept (id, user_id, dept_id, is_primary, created_at)
|
||||
VALUES (
|
||||
'20000000-0000-4000-8000-000000000004',
|
||||
'20000000-0000-4000-8000-000000000003',
|
||||
'20000000-0000-4000-8000-000000000001',
|
||||
true,
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 6) 用户-角色(绑定 tenant_admin)
|
||||
INSERT INTO iam_user_role (id, user_id, role_id, created_at)
|
||||
VALUES (
|
||||
'20000000-0000-4000-8000-000000000005',
|
||||
'20000000-0000-4000-8000-000000000003',
|
||||
'20000000-0000-4000-8000-000000000002',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 7) 回写租户管理员
|
||||
UPDATE iam_tenant
|
||||
SET admin_user_id = '20000000-0000-4000-8000-000000000003'
|
||||
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
-- 8) 将「超级管理员」与当前库中全部菜单关联(与 TenantService.Create 一致;无 iam_menu 数据时本步不插入行)
|
||||
INSERT INTO iam_role_menu (id, role_id, menu_id, created_at)
|
||||
SELECT gen_random_uuid()::text,
|
||||
'20000000-0000-4000-8000-000000000002',
|
||||
m.id,
|
||||
now()
|
||||
FROM iam_menu m
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- OAuth2 客户端与令牌表(Authorization Code + PKCE,opaque access/refresh token)
|
||||
-- 依赖 001_iam.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_client (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
client_id varchar(64) NOT NULL UNIQUE,
|
||||
client_secret_hash varchar(255) NULL,
|
||||
redirect_uris text NOT NULL,
|
||||
is_public boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_authorization_code (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
code_hash varchar(64) NOT NULL,
|
||||
client_id varchar(64) NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
scope text NOT NULL DEFAULT '',
|
||||
code_challenge varchar(128) NOT NULL,
|
||||
code_challenge_method varchar(16) NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_oauth_authorization_code_hash ON oauth_authorization_code (code_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_access_token (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
token_hash varchar(64) NOT NULL UNIQUE,
|
||||
client_id varchar(64) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
scope text NOT NULL DEFAULT '',
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS oauth_refresh_token (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
token_hash varchar(64) NOT NULL UNIQUE,
|
||||
access_token_id varchar(36) NOT NULL,
|
||||
client_id varchar(64) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
scope text NOT NULL DEFAULT '',
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_refresh_access ON oauth_refresh_token (access_token_id);
|
||||
|
||||
-- 开发用公开客户端(PKCE,无 secret);redirect 按实际前端修改
|
||||
INSERT INTO oauth_client (id, client_id, client_secret_hash, redirect_uris, is_public) VALUES (
|
||||
'30000000-0000-4000-8000-000000000001',
|
||||
'spa',
|
||||
NULL,
|
||||
'["http://localhost:5173/callback","http://127.0.0.1:5173/callback","http://localhost:3000/callback"]',
|
||||
true
|
||||
) ON CONFLICT (client_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,209 @@
|
||||
-- 全量业务菜单种子(与当前后端能力对齐:IAM 租户/部门/角色/用户/菜单 + System 参数)
|
||||
-- 依赖:001_iam.sql;建议在 004_seed_platform_builtin.sql 之后执行(以便角色已存在时可挂接权限)
|
||||
--
|
||||
-- 约定:
|
||||
-- menu_type: 1=目录 2=菜单 3=按钮(本脚本仅录入目录+菜单,便于侧栏 /nav)
|
||||
-- 「概览」(/dashboard) 不入库:全员可进,由前端壳层固定展示,不参与 iam_menu 配置与角色授权
|
||||
-- path 与 Next 管理端路由对齐(可按实际前端调整)
|
||||
--
|
||||
-- 固定菜单 id 前缀 31000000-0000-4000-8000-* 便于识别与文档引用
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO iam_menu (
|
||||
id, parent_id, menu_name, menu_type, perms, path, component, icon, sort_order,
|
||||
is_visible, is_builtin, status, created_at, updated_at
|
||||
) VALUES
|
||||
-- 工作台(目录)
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000001',
|
||||
'',
|
||||
'工作台',
|
||||
1,
|
||||
'workspace:root',
|
||||
'',
|
||||
'',
|
||||
'⌂',
|
||||
10,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000003',
|
||||
'31000000-0000-4000-8000-000000000001',
|
||||
'个人中心',
|
||||
2,
|
||||
'account:profile',
|
||||
'/dashboard/account',
|
||||
'',
|
||||
'👤',
|
||||
20,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
|
||||
-- 系统:/api/v1/system/param/*
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000010',
|
||||
'',
|
||||
'系统管理',
|
||||
1,
|
||||
'system:module',
|
||||
'',
|
||||
'',
|
||||
'⚙',
|
||||
40,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000011',
|
||||
'31000000-0000-4000-8000-000000000010',
|
||||
'参数配置',
|
||||
2,
|
||||
'system:param:list',
|
||||
'/dashboard/system/param',
|
||||
'',
|
||||
'🔧',
|
||||
10,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
|
||||
-- IAM:/api/v1/iam/*
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'',
|
||||
'权限管理',
|
||||
1,
|
||||
'iam:module',
|
||||
'',
|
||||
'',
|
||||
'🛡',
|
||||
50,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000021',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'租户管理',
|
||||
2,
|
||||
'iam:tenant:list',
|
||||
'/dashboard/iam/tenant',
|
||||
'',
|
||||
'🏢',
|
||||
10,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000022',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'部门管理',
|
||||
2,
|
||||
'iam:dept:tree',
|
||||
'/dashboard/iam/dept',
|
||||
'',
|
||||
'🌳',
|
||||
20,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000023',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'角色管理',
|
||||
2,
|
||||
'iam:role:list',
|
||||
'/dashboard/iam/role',
|
||||
'',
|
||||
'👥',
|
||||
30,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000024',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'用户管理',
|
||||
2,
|
||||
'iam:user:list',
|
||||
'/dashboard/iam/user',
|
||||
'',
|
||||
'👤',
|
||||
40,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
),
|
||||
(
|
||||
'31000000-0000-4000-8000-000000000025',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'资源(菜单)',
|
||||
2,
|
||||
'iam:menu:tree',
|
||||
'/dashboard/iam/resource',
|
||||
'',
|
||||
'📋',
|
||||
50,
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 将上述菜单授权给平台「超级管理员」角色(与 004 中 role id 一致)
|
||||
INSERT INTO iam_role_menu (id, role_id, menu_id, created_at)
|
||||
SELECT gen_random_uuid()::text,
|
||||
'20000000-0000-4000-8000-000000000002',
|
||||
m.id,
|
||||
now()
|
||||
FROM iam_menu m
|
||||
WHERE m.id IN (
|
||||
'31000000-0000-4000-8000-000000000001',
|
||||
'31000000-0000-4000-8000-000000000003',
|
||||
'31000000-0000-4000-8000-000000000010',
|
||||
'31000000-0000-4000-8000-000000000011',
|
||||
'31000000-0000-4000-8000-000000000020',
|
||||
'31000000-0000-4000-8000-000000000021',
|
||||
'31000000-0000-4000-8000-000000000022',
|
||||
'31000000-0000-4000-8000-000000000023',
|
||||
'31000000-0000-4000-8000-000000000024',
|
||||
'31000000-0000-4000-8000-000000000025'
|
||||
)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 若曾执行过含「概览」菜单的旧版脚本,可手工清理(避免侧栏与前端固定入口重复):
|
||||
-- DELETE FROM iam_role_menu WHERE menu_id = '31000000-0000-4000-8000-000000000002';
|
||||
-- DELETE FROM iam_menu WHERE id = '31000000-0000-4000-8000-000000000002';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,27 @@
|
||||
# PostgreSQL 脚本
|
||||
|
||||
与 `internal/iam/entity`、`internal/system/entity` 中的 GORM 模型对齐,便于手工建库或对照 AutoMigrate 结果。
|
||||
|
||||
**建议顺序**
|
||||
|
||||
1. `001_iam.sql` — IAM 表
|
||||
2. `002_system.sql` — `system_param`
|
||||
3. `003_seed_platform_tenant.sql`(可选)— 平台租户一行
|
||||
4. `004_seed_platform_builtin.sql`(可选)— 内置角色(`tenant_admin`、`user`)+ 平台租户下默认管理员 `admin` / `Admin@123`,并为 `tenant_admin` 绑定当前 `iam_menu` 中全部菜单(与 `TenantService.Create` 行为一致)
|
||||
5. `005_oauth.sql`(可选)— OAuth2 客户端与令牌表,并插入开发用公开客户端 `spa`
|
||||
6. `006_seed_iam_menu_full.sql`(可选)— 与当前后端模块对齐的侧栏菜单(不含「概览」:`/dashboard` 由前端壳层固定展示、全员可进);含工作台下「个人中心」、系统参数、IAM 子模块;并为平台超级管理员补 `iam_role_menu`(`ON CONFLICT DO NOTHING`)
|
||||
|
||||
**执行示例**
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -f migrations/postgres/001_iam.sql
|
||||
psql "$DATABASE_URL" -f migrations/postgres/002_system.sql
|
||||
psql "$DATABASE_URL" -f migrations/postgres/003_seed_platform_tenant.sql
|
||||
psql "$DATABASE_URL" -f migrations/postgres/004_seed_platform_builtin.sql
|
||||
psql "$DATABASE_URL" -f migrations/postgres/005_oauth.sql
|
||||
psql "$DATABASE_URL" -f migrations/postgres/006_seed_iam_menu_full.sql
|
||||
```
|
||||
|
||||
生产环境请在首次登录后修改默认密码;若需更换哈希,可用与业务相同的 `bcrypt.DefaultCost` 重新生成 `password_hash` 再更新 `iam_user`。
|
||||
|
||||
若已使用 GORM `AutoMigrate`,可将本目录脚本作为文档或与迁移工具对照,避免重复执行冲突。
|
||||
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"giter.top/smart/pkg/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// NewRedis 根据配置创建 Redis 客户端,支持单机、哨兵、集群三种模式。
|
||||
func NewRedis(cfg *config.Config) (redis.UniversalClient) {
|
||||
if cfg == nil {
|
||||
panic("cache: config is nil")
|
||||
}
|
||||
r := cfg.Data.Redis
|
||||
mode := strings.ToLower(strings.TrimSpace(r.Mode))
|
||||
if mode == "" {
|
||||
mode = "standalone"
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "standalone":
|
||||
addr, err := standaloneAddr(cfg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
opt := &redis.Options{
|
||||
Addr: addr,
|
||||
DB: r.DB,
|
||||
}
|
||||
applyCommonToClient(opt, cfg)
|
||||
return redis.NewClient(opt)
|
||||
|
||||
case "sentinel":
|
||||
if strings.TrimSpace(r.MasterName) == "" {
|
||||
panic("cache: redis sentinel requires master_name")
|
||||
}
|
||||
if len(r.Addrs) == 0 {
|
||||
panic("cache: redis sentinel requires addrs (sentinel 节点列表)")
|
||||
}
|
||||
opt := &redis.FailoverOptions{
|
||||
MasterName: r.MasterName,
|
||||
SentinelAddrs: r.Addrs,
|
||||
DB: r.DB,
|
||||
}
|
||||
applyCommonToFailover(opt, cfg)
|
||||
return redis.NewFailoverClient(opt)
|
||||
|
||||
case "cluster":
|
||||
if len(r.Addrs) == 0 {
|
||||
panic("cache: redis cluster requires addrs")
|
||||
}
|
||||
opt := &redis.ClusterOptions{
|
||||
Addrs: r.Addrs,
|
||||
}
|
||||
applyCommonToCluster(opt, cfg)
|
||||
return redis.NewClusterClient(opt)
|
||||
|
||||
default:
|
||||
panic(fmt.Sprintf("cache: unsupported redis mode %q", r.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
func standaloneAddr(cfg *config.Config) (string, error) {
|
||||
r := cfg.Data.Redis
|
||||
if strings.TrimSpace(r.Addr) != "" {
|
||||
return r.Addr, nil
|
||||
}
|
||||
if len(r.Addrs) > 0 && strings.TrimSpace(r.Addrs[0]) != "" {
|
||||
return r.Addrs[0], nil
|
||||
}
|
||||
return "", errors.New("cache: redis standalone requires addr or addrs[0]")
|
||||
}
|
||||
|
||||
// Ping 用于启动时探测连接是否可用。
|
||||
func Ping(ctx context.Context, c redis.UniversalClient) error {
|
||||
if c == nil {
|
||||
return errors.New("cache: redis client is nil")
|
||||
}
|
||||
return c.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
func applyCommonToClient(opt *redis.Options, cfg *config.Config) {
|
||||
r := cfg.Data.Redis
|
||||
opt.Username = r.Username
|
||||
opt.Password = r.Password
|
||||
if r.PoolSize > 0 {
|
||||
opt.PoolSize = r.PoolSize
|
||||
}
|
||||
if r.MinIdleConns > 0 {
|
||||
opt.MinIdleConns = r.MinIdleConns
|
||||
}
|
||||
if r.MaxRetries != 0 {
|
||||
opt.MaxRetries = r.MaxRetries
|
||||
}
|
||||
if r.RetryDelay > 0 {
|
||||
opt.MinRetryBackoff = r.RetryDelay
|
||||
}
|
||||
if r.RetryMaxDelay > 0 {
|
||||
opt.MaxRetryBackoff = r.RetryMaxDelay
|
||||
}
|
||||
if r.DialTimeout > 0 {
|
||||
opt.DialTimeout = r.DialTimeout
|
||||
}
|
||||
if r.ReadTimeout > 0 {
|
||||
opt.ReadTimeout = r.ReadTimeout
|
||||
}
|
||||
if r.WriteTimeout > 0 {
|
||||
opt.WriteTimeout = r.WriteTimeout
|
||||
}
|
||||
if r.IdleTimeout > 0 {
|
||||
opt.ConnMaxIdleTime = r.IdleTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func applyCommonToFailover(opt *redis.FailoverOptions, cfg *config.Config) {
|
||||
r := cfg.Data.Redis
|
||||
opt.Username = r.Username
|
||||
opt.Password = r.Password
|
||||
if r.PoolSize > 0 {
|
||||
opt.PoolSize = r.PoolSize
|
||||
}
|
||||
if r.MinIdleConns > 0 {
|
||||
opt.MinIdleConns = r.MinIdleConns
|
||||
}
|
||||
if r.MaxRetries != 0 {
|
||||
opt.MaxRetries = r.MaxRetries
|
||||
}
|
||||
if r.RetryDelay > 0 {
|
||||
opt.MinRetryBackoff = r.RetryDelay
|
||||
}
|
||||
if r.RetryMaxDelay > 0 {
|
||||
opt.MaxRetryBackoff = r.RetryMaxDelay
|
||||
}
|
||||
if r.DialTimeout > 0 {
|
||||
opt.DialTimeout = r.DialTimeout
|
||||
}
|
||||
if r.ReadTimeout > 0 {
|
||||
opt.ReadTimeout = r.ReadTimeout
|
||||
}
|
||||
if r.WriteTimeout > 0 {
|
||||
opt.WriteTimeout = r.WriteTimeout
|
||||
}
|
||||
if r.IdleTimeout > 0 {
|
||||
opt.ConnMaxIdleTime = r.IdleTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func applyCommonToCluster(opt *redis.ClusterOptions, cfg *config.Config) {
|
||||
r := cfg.Data.Redis
|
||||
opt.Username = r.Username
|
||||
opt.Password = r.Password
|
||||
if r.PoolSize > 0 {
|
||||
opt.PoolSize = r.PoolSize
|
||||
}
|
||||
if r.MinIdleConns > 0 {
|
||||
opt.MinIdleConns = r.MinIdleConns
|
||||
}
|
||||
if r.MaxRetries != 0 {
|
||||
opt.MaxRetries = r.MaxRetries
|
||||
}
|
||||
if r.RetryDelay > 0 {
|
||||
opt.MinRetryBackoff = r.RetryDelay
|
||||
}
|
||||
if r.RetryMaxDelay > 0 {
|
||||
opt.MaxRetryBackoff = r.RetryMaxDelay
|
||||
}
|
||||
if r.DialTimeout > 0 {
|
||||
opt.DialTimeout = r.DialTimeout
|
||||
}
|
||||
if r.ReadTimeout > 0 {
|
||||
opt.ReadTimeout = r.ReadTimeout
|
||||
}
|
||||
if r.WriteTimeout > 0 {
|
||||
opt.WriteTimeout = r.WriteTimeout
|
||||
}
|
||||
if r.IdleTimeout > 0 {
|
||||
opt.ConnMaxIdleTime = r.IdleTimeout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server struct {
|
||||
Http struct {
|
||||
Addr string `mapstructure:"addr"`
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
} `mapstructure:"http"`
|
||||
Grpc struct {
|
||||
Addr string `mapstructure:"addr"`
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
} `mapstructure:"grpc"`
|
||||
} `mapstructure:"server"`
|
||||
Data struct {
|
||||
Database struct {
|
||||
Driver string `mapstructure:"driver"`
|
||||
DSN string `mapstructure:"dsn"`
|
||||
} `mapstructure:"database"`
|
||||
Redis struct {
|
||||
// Mode: standalone(单机)、sentinel(哨兵)、cluster(集群)
|
||||
Mode string `mapstructure:"mode"`
|
||||
Addr string `mapstructure:"addr"`
|
||||
Addrs []string `mapstructure:"addrs"`
|
||||
Password string `mapstructure:"password"`
|
||||
Username string `mapstructure:"username"`
|
||||
DB int `mapstructure:"db"`
|
||||
MasterName string `mapstructure:"master_name"`
|
||||
PoolSize int `mapstructure:"pool_size"`
|
||||
// MinIdleConns 最小空闲连接数
|
||||
MinIdleConns int `mapstructure:"min_idle_conns"`
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
RetryDelay time.Duration `mapstructure:"retry_delay"`
|
||||
RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"`
|
||||
DialTimeout time.Duration `mapstructure:"dial_timeout"`
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"`
|
||||
WriteTimeout time.Duration `mapstructure:"write_timeout"`
|
||||
// IdleTimeout 映射为 go-redis ConnMaxIdleTime
|
||||
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
|
||||
} `mapstructure:"redis"`
|
||||
} `mapstructure:"data"`
|
||||
// Auth 认证域(OAuth2、会话等);PublicBaseURL 为浏览器可访问的后端根 URL(用于登录回跳拼接 /oauth/authorize)
|
||||
Auth struct {
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
OAuth2 struct {
|
||||
FrontendLoginURL string `mapstructure:"frontend_login_url"`
|
||||
AuthCodeTTL time.Duration `mapstructure:"auth_code_ttl"`
|
||||
AccessTokenTTL time.Duration `mapstructure:"access_token_ttl"`
|
||||
RefreshTokenTTL time.Duration `mapstructure:"refresh_token_ttl"`
|
||||
} `mapstructure:"oauth2"`
|
||||
Session struct {
|
||||
CookieName string `mapstructure:"cookie_name"`
|
||||
CookieDomain string `mapstructure:"cookie_domain"`
|
||||
CookieSecure bool `mapstructure:"cookie_secure"`
|
||||
SameSite string `mapstructure:"same_site"` // lax, strict, none
|
||||
TTL time.Duration `mapstructure:"ttl"`
|
||||
} `mapstructure:"session"`
|
||||
// RateLimit 登录与令牌端点限流(进程内按 IP;多实例需网关或 Redis 限流)
|
||||
RateLimit struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
LoginPerMinute int `mapstructure:"login_per_minute"`
|
||||
TokenPerMinute int `mapstructure:"token_per_minute"`
|
||||
} `mapstructure:"rate_limit"`
|
||||
} `mapstructure:"auth"`
|
||||
}
|
||||
|
||||
// 加载配置文件
|
||||
func Load(path string) (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config Config
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"giter.top/smart/pkg/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewDB(cfg *config.Config) *gorm.DB {
|
||||
driver := cfg.Data.Database.Driver
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
switch driver {
|
||||
case "mysql":
|
||||
// db, err = NewMySQLDB(cfg)
|
||||
case "postgres":
|
||||
db, err = NewPgSQLDB(cfg)
|
||||
case "sqlite":
|
||||
// return NewSQLiteDB(cfg)
|
||||
default:
|
||||
panic("unsupported driver")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package db
|
||||
@@ -0,0 +1,18 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"giter.top/smart/pkg/config"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// pg sql 数据库连接
|
||||
func NewPgSQLDB(cfg *config.Config) (*gorm.DB , error) {
|
||||
db, err := gorm.Open(postgres.Open(cfg.Data.Database.DSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to connect to postgres database")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// RandomURLSafe 生成 URL-safe 随机串(用于 opaque token、authorization code 等)。
|
||||
func RandomURLSafe(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package codec
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword 将明文密码生成为 bcrypt 哈希字符串(与业务中 bcrypt.DefaultCost 一致)。
|
||||
func HashPassword(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 校验明文是否与 bcrypt 哈希匹配。
|
||||
// password 明文密码
|
||||
// hashedPassword 哈希密码
|
||||
func VerifyPassword(password,hashedPassword string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package id
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
generator IDGenerator
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
type IDGenerator interface {
|
||||
generate() string
|
||||
}
|
||||
|
||||
func New() string {
|
||||
once.Do(func() {
|
||||
generator = NewUUIDGenerator()
|
||||
})
|
||||
return generator.generate()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package id
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type UUIDGenerator struct {
|
||||
}
|
||||
|
||||
func NewUUIDGenerator() IDGenerator {
|
||||
return &UUIDGenerator{}
|
||||
}
|
||||
|
||||
func (g *UUIDGenerator) generate() string {
|
||||
id, _ := uuid.NewV7()
|
||||
return id.String()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Go HTTP 根地址(无尾部斜杠,不含 /api/v1)
|
||||
NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000
|
||||
|
||||
# OAuth 公开客户端(与 Go 种子 client 一致)
|
||||
NEXT_PUBLIC_OAUTH_CLIENT_ID=spa
|
||||
# 换 token 时须与登录请求 redirect_uri 一致(本机开发)
|
||||
NEXT_PUBLIC_OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback
|
||||
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
node_modules
|
||||
out
|
||||
build
|
||||
package-lock.json
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Web(Next.js)
|
||||
|
||||
对接 [`../docs/auth-api.md`](../docs/auth-api.md) 与 Go `/api/v1`。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_API_ORIGIN` 指向 Go 监听地址(默认开发 `http://127.0.0.1:8000`)。
|
||||
|
||||
## 目录约定
|
||||
|
||||
- `lib/api/` — HTTP 客户端与各领域 API
|
||||
- `stores/` — Zustand 状态
|
||||
- `lib/env.ts` — 公共环境变量读取
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { IamSectionCard } from '@/components/iam/IamSectionCard';
|
||||
import { MenuTreeView } from '@/components/iam/MenuTreeView';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { iamMenu } from '@/lib/api/iam';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
|
||||
export default function IamResourcePage() {
|
||||
const { data, loading, error } = useApi<MenuNode[]>(() => iamMenu.tree());
|
||||
|
||||
return (
|
||||
<IamSectionCard
|
||||
title="资源(菜单)"
|
||||
description="全局菜单树,对接 GET /api/v1/iam/menu/tree;与侧栏 nav 数据源一致(nav 会按角色过滤)。"
|
||||
>
|
||||
{loading ? <p className="text-sm text-neutral-500">加载中…</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{!loading && !error && data ? <MenuTreeView tree={data} /> : null}
|
||||
</IamSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { IamSectionCard } from '@/components/iam/IamSectionCard';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { iamTenant } from '@/lib/api/iam';
|
||||
import type { IamTenant } from '@/lib/api/types/tenant';
|
||||
|
||||
export default function IamTenantPage() {
|
||||
const { data, loading, error } = useApi(() => iamTenant.list({ page: '1', page_size: '100' }));
|
||||
const rows = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<IamSectionCard
|
||||
title="租户管理"
|
||||
description="对接 GET /api/v1/iam/tenant/list,后续可接新增/编辑/删除。"
|
||||
>
|
||||
{loading ? <p className="text-sm text-neutral-500">加载中…</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{!loading && !error ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[480px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 text-left text-neutral-600">
|
||||
<th className="py-2 pr-2">租户名称</th>
|
||||
<th className="py-2 pr-2">编码</th>
|
||||
<th className="py-2 pr-2">状态</th>
|
||||
<th className="py-2">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-neutral-100">
|
||||
<td className="py-2 pr-2">{r.tenant_name ?? '—'}</td>
|
||||
<td className="py-2 pr-2 font-mono text-xs">{r.tenant_code ?? '—'}</td>
|
||||
<td className="py-2 pr-2">{r.status ?? '—'}</td>
|
||||
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!rows.length ? <p className="mt-2 text-neutral-500">暂无租户</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</IamSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { DeptTreeView } from '@/components/iam/DeptTreeView';
|
||||
import { IamSectionCard } from '@/components/iam/IamSectionCard';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { iamDept } from '@/lib/api/iam';
|
||||
import type { DeptNode } from '@/lib/api/types/dept';
|
||||
|
||||
export default function IamDeptPage() {
|
||||
const { data, loading, error } = useApi<DeptNode[]>(() => iamDept.tree());
|
||||
|
||||
return (
|
||||
<IamSectionCard title="部门管理" description="对接 GET /api/v1/iam/dept/tree。">
|
||||
{loading ? <p className="text-sm text-neutral-500">加载中…</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{!loading && !error && data ? <DeptTreeView tree={data} /> : null}
|
||||
</IamSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { IamSectionCard } from '@/components/iam/IamSectionCard';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { iamRole } from '@/lib/api/iam';
|
||||
import type { IamRole } from '@/lib/api/types/role';
|
||||
|
||||
export default function IamRolePage() {
|
||||
const { data, loading, error } = useApi(() => iamRole.list({ page: '1', page_size: '50' }));
|
||||
const rows = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<IamSectionCard title="角色管理" description="对接 GET /api/v1/iam/role/list。">
|
||||
{loading ? <p className="text-sm text-neutral-500">加载中…</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{!loading && !error ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[480px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 text-left text-neutral-600">
|
||||
<th className="py-2 pr-2">角色名</th>
|
||||
<th className="py-2 pr-2">编码</th>
|
||||
<th className="py-2 pr-2">数据范围</th>
|
||||
<th className="py-2">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-neutral-100">
|
||||
<td className="py-2 pr-2">{r.role_name ?? '—'}</td>
|
||||
<td className="py-2 pr-2 font-mono text-xs">{r.role_code ?? '—'}</td>
|
||||
<td className="py-2 pr-2">{r.data_scope ?? '—'}</td>
|
||||
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!rows.length ? <p className="mt-2 text-neutral-500">暂无角色</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</IamSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { IamSectionCard } from '@/components/iam/IamSectionCard';
|
||||
import { useApi } from '@/lib/hooks/use-api';
|
||||
import { iamUser } from '@/lib/api/iam';
|
||||
import type { IamUser } from '@/lib/api/types/user';
|
||||
|
||||
export default function IamUserPage() {
|
||||
const { data, loading, error } = useApi(() => iamUser.list({ page: '1', page_size: '20' }));
|
||||
const rows = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<IamSectionCard title="用户管理" description="对接 GET /api/v1/iam/user/list。">
|
||||
{loading ? <p className="text-sm text-neutral-500">加载中…</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{!loading && !error ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[520px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 text-left text-neutral-600">
|
||||
<th className="py-2 pr-2">用户名</th>
|
||||
<th className="py-2 pr-2">姓名</th>
|
||||
<th className="py-2 pr-2">状态</th>
|
||||
<th className="py-2">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-neutral-100">
|
||||
<td className="py-2 pr-2">{r.user_name ?? '—'}</td>
|
||||
<td className="py-2 pr-2">{r.real_name ?? '—'}</td>
|
||||
<td className="py-2 pr-2">{r.status ?? '—'}</td>
|
||||
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!rows.length ? <p className="mt-2 text-neutral-500">暂无用户</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</IamSectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
|
||||
<h1 className="text-lg font-medium">个人中心</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">资料与密码修改等功能可在此页对接 IAM。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
|
||||
<h1 className="text-lg font-medium">概览</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">后续在此接 Tabs + 业务页。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
|
||||
/**
|
||||
* 单一布局包裹 /dashboard 与 /user 等路由,避免 Tab 在二者间切换时卸载布局、
|
||||
* 导致 AppChrome 重挂载并重复请求侧栏菜单。
|
||||
*/
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AuthenticatedLayout>{children}</AuthenticatedLayout>;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,55 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* 收起态侧栏:一级悬停展开的二级浮层入场(略慢、缓出更柔和) */
|
||||
@keyframes cascade-flyout-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cascade-flyout-panel {
|
||||
animation: cascade-flyout-in 0.4s cubic-bezier(0.33, 1, 0.68, 1) both;
|
||||
}
|
||||
|
||||
@keyframes cascade-flyout-sub-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cascade-flyout-sub {
|
||||
animation: cascade-flyout-sub-in 0.32s cubic-bezier(0.33, 1, 0.68, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cascade-flyout-panel,
|
||||
.cascade-flyout-sub {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { AppProviders } from '@/components/providers/AppProviders';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Smart Go',
|
||||
description: 'Smart Go 管理平台',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { safeReturnPath } from '@/lib/navigation/safe-return';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [tenant, setTenant] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !accessToken) {
|
||||
return;
|
||||
}
|
||||
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
|
||||
router.replace(next);
|
||||
}, [mounted, accessToken, router, searchParams]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
|
||||
加载中…
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
|
||||
正在进入后台…
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(user, pass, tenant || undefined);
|
||||
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
|
||||
router.replace(next);
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h1 className="text-lg font-medium">登录</h1>
|
||||
<label className="mt-4 block text-sm">
|
||||
<span className="text-neutral-600">用户名</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-sm">
|
||||
<span className="text-neutral-600">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block text-sm">
|
||||
<span className="text-neutral-600">租户 ID(可空)</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={tenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{err ? <p className="mt-3 text-sm text-red-600">{err}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-4 w-full rounded bg-neutral-900 py-2 text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? '提交中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<Link href="/" className="text-sm text-blue-600">
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
|
||||
加载中…
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { exchangeCodeForTokens } from '@/lib/api/auth';
|
||||
import { getOAuthClientId, getOAuthRedirectUri } from '@/lib/env';
|
||||
import { takeStoredPkceVerifier } from '@/lib/oauth/browser';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
function CallbackInner() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [msg, setMsg] = useState<string>('处理中…');
|
||||
|
||||
useEffect(() => {
|
||||
const code = sp.get('code');
|
||||
const err = sp.get('error');
|
||||
if (err) {
|
||||
setMsg(sp.get('error_description') || err);
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
setMsg('缺少授权码');
|
||||
return;
|
||||
}
|
||||
const verifier = takeStoredPkceVerifier();
|
||||
if (!verifier) {
|
||||
setMsg('缺少 PKCE verifier,请从授权入口重新登录');
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const pair = await exchangeCodeForTokens({
|
||||
code,
|
||||
codeVerifier: verifier,
|
||||
clientId: getOAuthClientId(),
|
||||
redirectUri: getOAuthRedirectUri(),
|
||||
});
|
||||
useAuthStore.getState().setTokens(pair.accessToken, pair.refreshToken);
|
||||
setMsg('登录成功,正在跳转…');
|
||||
router.replace('/dashboard');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
}, [sp, router]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-neutral-600">{msg}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthCallbackPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p>加载中…</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<CallbackInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
|
||||
<h1 className="text-2xl font-semibold text-neutral-800">Smart Go · Web</h1>
|
||||
<p className="max-w-md text-center text-neutral-600">
|
||||
对接 Go `NEXT_PUBLIC_API_ORIGIN`,支持账号密码 + PKCE 换 token,或浏览器 OAuth 授权码流程。
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/login" className="rounded-lg bg-neutral-900 px-5 py-2 text-white">
|
||||
登录
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg border border-neutral-300 px-5 py-2 text-neutral-800"
|
||||
>
|
||||
工作台
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAuthUiStore } from '@/stores/auth-ui-store';
|
||||
|
||||
export function LoginModal() {
|
||||
const open = useAuthUiStore((s) => s.loginModalOpen);
|
||||
const close = useAuthUiStore((s) => s.closeLoginModal);
|
||||
const hint = useAuthUiStore((s) => s.loginHint);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const router = useRouter();
|
||||
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [tenant, setTenant] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(user, pass, tenant || undefined);
|
||||
close();
|
||||
setUser('');
|
||||
setPass('');
|
||||
setTenant('');
|
||||
router.refresh();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-280 bg-black/40" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-280 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-neutral-200 bg-white p-5 shadow-xl outline-none focus:outline-none"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Title id="relogin-title" className="text-lg font-medium text-neutral-900">
|
||||
重新登录
|
||||
</Dialog.Title>
|
||||
{hint ? (
|
||||
<Dialog.Description className="mt-1 text-sm text-neutral-600">{hint}</Dialog.Description>
|
||||
) : (
|
||||
<Dialog.Description className="sr-only">请输入用户名与密码以继续操作。</Dialog.Description>
|
||||
)}
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">用户名</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">租户 ID(可空)</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={tenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{err ? <p className="text-sm text-red-600">{err}</p> : null}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-neutral-300 px-3 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
close();
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-neutral-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? '提交中…' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
export function ToastHost() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const dismiss = useToastStore((s) => s.dismiss);
|
||||
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[200] flex max-w-sm flex-col gap-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 text-sm shadow-lg ${
|
||||
t.variant === 'error'
|
||||
? 'border-red-200 bg-red-50 text-red-900'
|
||||
: 'border-neutral-200 bg-white text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="min-w-0 flex-1">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-neutral-400 hover:text-neutral-700"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import type { DeptNode } from '@/lib/api/types/dept';
|
||||
|
||||
function DeptNodes({ nodes, depth }: { nodes: DeptNode[]; depth: number }) {
|
||||
return (
|
||||
<ul className={depth === 0 ? 'space-y-1' : 'ml-4 mt-1 space-y-1 border-l border-neutral-200 pl-3'}>
|
||||
{nodes.map((n) => (
|
||||
<li key={n.id} className="text-sm">
|
||||
<span className="text-neutral-800">{n.dept_name}</span>
|
||||
{n.children?.length ? <DeptNodes nodes={n.children} depth={depth + 1} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeptTreeView(props: { tree: DeptNode[] }) {
|
||||
if (!props.tree.length) {
|
||||
return <p className="text-sm text-neutral-500">暂无数据</p>;
|
||||
}
|
||||
return <DeptNodes nodes={props.tree} depth={0} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user