feat: 优化web
This commit is contained in:
@@ -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 场景)。
|
||||
Reference in New Issue
Block a user