feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
@@ -0,0 +1,568 @@
---
name: Next 全量对接 Go API
overview: 全栈对接 Go APIOAuth2 Code + PKCE**401/403 与 200+`code` 分工**(**含:密码错=401,只看状态**);JSON 信封;login 的 401 不 refreshdev 跨域;登出多 Tabauth/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: 实现 apiClientJSON + BearerOAuth token 用 form);auth/login 取 code 再 oauth/tokenlogoutZustand 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 补齐 iamtenant/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;经典/图标+localStoragepath 映射路由(首版 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、(若前端自己做 PKCEauthorize 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
+-----------------------------------------------------------------------+
| +-- 白卡片 ----------------------------------------------------------------+ |
| | +------------------+ +------------------------------------------------+ | |
| | | 部门 / 组织树 | | [+新增] [批量删除] [条件…] [查询] [重置] | | |
| | | ▾ 公司 | +------------------------------------------------+ | |
| | | ▾ 研发部 | | 表头 | 操作(修改/删除) | … | | |
| | | ▾ 市场部 | | 数据 | [改][删] | … | | |
| | | … | +------------------------------------------------+ | |
| | | (可选宽 240280) | | 共 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 图标模式(约定含义)
业内常见两种叫法,与你描述的「经典 / 图标」一般对应如下(若你希望另一种交互,可再改一版文案):
| 模式 | 典型形态 | 体验要点 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| **经典模式** | 侧栏 **较宽**(约 220260px),每项 **图标 + 文字** 并排展示;多级菜单可展开/折叠,**当前选中态**清晰 | 信息密度高,适合首次使用与菜单项较多的管理后台 |
| **图标模式** | 侧栏 **收窄**(约 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 | +---------------------------------------------------------------+ |
| 用户 | | 表格 / 卡片 / 步骤条 … | |
| 角色 | +---------------------------------------------------------------+ |
| ... | |
| | |
+----------+-----------------------------------------------------------------------+
^约 220260px^
```
**图标模式(窄侧栏:仅图标,文字用 Tooltip)**
```text
+----------------------------------------------------------------------------------+
| [Logo] Smart Admin [租户] [用户 ▼] [退出] ←「用户」下拉内含侧栏经典/图标 |
+--+-------------------------------------------------------------------------------+
|[]| |
|[]| 主内容区(更宽) |
|[]| |
|[]| +---------------------------------------------------------------+ |
|[]| | | |
|[]| +---------------------------------------------------------------+ |
| | |
+--+-------------------------------------------------------------------------------+
^约 5672px^
悬停图标 → 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/RedisRS 中间件按 token 查元数据完成校验;可选暴露 RFC 7662 introspection 供未来独立资源服务。前后端分离下后端不渲染 HTML,仅 JSON 登录与 302OAuth 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/tokenauthorization_code + S256 PKCE + state
status: completed
- id: opaque-token-mw
content: 签发 opaque access token(高熵随机串,元数据存库)、Gin Bearer 中间件内联查库/Redis 解析用户与 scope;可选 POST /oauth/introspectRFC 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 或 RedisRedis 更适合极短 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 场景)。