mirror of
https://github.com/KwiTsukasa/kt-template-online-api.git
synced 2026-05-27 15:44:54 +08:00
fix(api): 优化WordPress降级和后台权限
This commit is contained in:
parent
755103f403
commit
74dad7d70d
@ -15,6 +15,8 @@ WORDPRESS_BASE_URL=http://localhost
|
||||
WORDPRESS_ADMIN_USERNAME=admin
|
||||
WORDPRESS_ADMIN_PASSWORD=
|
||||
WORDPRESS_TIMEOUT_MS=15000
|
||||
WORDPRESS_LOGIN_TIMEOUT_MS=3000
|
||||
WORDPRESS_AVAILABILITY_TTL_MS=60000
|
||||
|
||||
ADMIN_TOKEN_SECRET=change-me
|
||||
ADMIN_COOKIE_SECURE=false
|
||||
|
||||
188
API.md
188
API.md
@ -24,14 +24,14 @@
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| --------- | --------------------------------------------------------------------- |
|
||||
| 模块 | 说明 |
|
||||
| --------- | ----------------------------------------------------------------------------------------- |
|
||||
| Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
|
||||
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
|
||||
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
|
||||
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
|
||||
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
||||
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
|
||||
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
|
||||
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
|
||||
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
|
||||
|
||||
## 通用规则
|
||||
|
||||
@ -61,49 +61,53 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `WORDPRESS_BASE_URL` | WordPress 站点根地址,例如 `http://192.168.31.224:8080` |
|
||||
| `WORDPRESS_ADMIN_USERNAME` | WordPress 单管理员账号用户名 |
|
||||
| `WORDPRESS_ADMIN_PASSWORD` | WordPress 单管理员账号密码,仅放真实 env,不提交到仓库 |
|
||||
| `WORDPRESS_TIMEOUT_MS` | WordPress REST API 请求超时时间,默认 `15000` |
|
||||
| 变量 | 说明 |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `WORDPRESS_BASE_URL` | WordPress 站点根地址,例如 `http://192.168.31.224:8080` |
|
||||
| `WORDPRESS_ADMIN_USERNAME` | WordPress 单管理员账号用户名 |
|
||||
| `WORDPRESS_ADMIN_PASSWORD` | WordPress 单管理员账号密码,仅放真实 env,不提交到仓库 |
|
||||
| `WORDPRESS_TIMEOUT_MS` | WordPress REST API 请求超时时间,默认 `15000` |
|
||||
| `WORDPRESS_LOGIN_TIMEOUT_MS` | Admin 登录链路里 WordPress 自动认证的短超时时间,默认 `3000`,避免远程不可用阻塞主系统登录 |
|
||||
| `WORDPRESS_AVAILABILITY_TTL_MS` | WordPress 可用性缓存时间,默认 `60000`;远程不可用时用于过滤博客菜单和按钮权限 |
|
||||
|
||||
支持的 WordPress 登录态来源:
|
||||
|
||||
| Header/Cookie | 说明 |
|
||||
| --- | --- |
|
||||
| `X-WordPress-Authorization` | 优先透传的 WordPress 授权头,例如客户端登录拿到的 `Bearer <token>` |
|
||||
| `Authorization` | 仅当它不是本系统 Admin access token 时才会透传,避免和后台认证冲突 |
|
||||
| `X-WP-Nonce` | WordPress REST cookie 认证 nonce |
|
||||
| `Cookie` | 只会过滤并透传 `wordpress_*`、`wordpress_logged_in_*`、`wp-settings-*` 等 WordPress 登录相关 cookie |
|
||||
| `X-WordPress-Cookie` | 显式传入 WordPress cookie,适合非浏览器客户端联调 |
|
||||
| `kt_wordpress_auth` | 后端自动认证后写入的 httpOnly cookie,前端不可读取,后端会自动转成 WordPress cookie 透传 |
|
||||
| Header/Cookie | 说明 |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `X-WordPress-Authorization` | 优先透传的 WordPress 授权头,例如客户端登录拿到的 `Bearer <token>` |
|
||||
| `Authorization` | 仅当它不是本系统 Admin access token 时才会透传,避免和后台认证冲突 |
|
||||
| `X-WP-Nonce` | WordPress REST cookie 认证 nonce |
|
||||
| `Cookie` | 只会过滤并透传 `wordpress_*`、`wordpress_logged_in_*`、`wp-settings-*` 等 WordPress 登录相关 cookie |
|
||||
| `X-WordPress-Cookie` | 显式传入 WordPress cookie,适合非浏览器客户端联调 |
|
||||
| `kt_wordpress_auth` | 后端自动认证后写入的 httpOnly cookie,前端不可读取,后端会自动转成 WordPress cookie 透传 |
|
||||
|
||||
如果 WordPress 所在 Apache/Nginx 未开启 rewrite,`/wp-json/*` 可能返回 404。后端会自动回退到 WordPress 原生 `?rest_route=/...` 形式,避免因为固定链接配置阻断文章、标签和分类管理接口。
|
||||
|
||||
Admin 主登录不依赖 WordPress 可用性:本系统账号验证通过后会先写入 Admin token;WordPress 自动认证失败时登录仍返回成功,`wordpressAuth` 为 `null`、`wordpressAvailable=false`,并清理旧 WordPress cookie。随后 `/menu/all` 与 `/auth/codes` 会基于最近一次 WordPress 可用性状态过滤 `Blog*` 菜单和 `Blog:*` 按钮权限码,避免前端展示不可用的文章、分类、标签管理入口。
|
||||
|
||||
### 数据库字典翻译
|
||||
|
||||
组件数据维护在 `admin_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容,但仍需要登录态。
|
||||
|
||||
`admin_dict` 表核心字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------- | ------- | ------------------------------------------------------ |
|
||||
| id | string | 字典数字 ID |
|
||||
| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
|
||||
| label | string | 展示文本 |
|
||||
| value | string | 字典值 |
|
||||
| childrenCode | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` |
|
||||
| sort | number | 排序 |
|
||||
| status | number | 启停状态,`1` 启用 |
|
||||
| isDeleted | boolean | 逻辑删除标记 |
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------ | ------- | ------------------------------------------------------ |
|
||||
| id | string | 字典数字 ID |
|
||||
| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
|
||||
| label | string | 展示文本 |
|
||||
| value | string | 字典值 |
|
||||
| childrenCode | string | 子字典分组,例如 `COMPONENT_TYPE.value=1` 指向 `CHART` |
|
||||
| sort | number | 排序 |
|
||||
| status | number | 启停状态,`1` 启用 |
|
||||
| isDeleted | boolean | 逻辑删除标记 |
|
||||
|
||||
当前数据库示例关系:
|
||||
|
||||
| dictCode | value | label | childrenCode |
|
||||
| -------------- | ----- | ----- | ----------- |
|
||||
| COMPONENT_TYPE | 1 | 图表 | CHART |
|
||||
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
|
||||
| -------------- | ----- | ----- | ------------ |
|
||||
| COMPONENT_TYPE | 1 | 图表 | CHART |
|
||||
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
|
||||
|
||||
## 数据结构
|
||||
|
||||
@ -323,35 +327,35 @@ Query:
|
||||
|
||||
核心接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/auth/login` | 登录,返回 `accessToken` 与 `wordpressAuth`,并写入 access token、刷新 token 和 WordPress 授权 cookie |
|
||||
| POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken,并更新 token cookie |
|
||||
| POST | `/auth/logout` | 退出登录并清理 access token、刷新 token 与 WordPress 授权 cookie |
|
||||
| GET | `/auth/codes` | 获取当前用户权限码 |
|
||||
| GET | `/user/info` | 获取当前用户信息 |
|
||||
| GET | `/menu/all` | 获取当前用户可访问菜单 |
|
||||
| GET | `/system/menu/list` | 获取系统菜单树 |
|
||||
| GET | `/system/menu/name-exists` | 校验菜单 name 是否重复 |
|
||||
| GET | `/system/menu/path-exists` | 校验菜单 path 是否重复 |
|
||||
| POST | `/system/menu` | 新增菜单 |
|
||||
| PUT | `/system/menu/:id` | 更新菜单 |
|
||||
| DELETE | `/system/menu/:id` | 删除菜单及子菜单 |
|
||||
| GET | `/system/role/list` | 分页查询角色 |
|
||||
| POST | `/system/role` | 新增角色 |
|
||||
| PUT | `/system/role/:id` | 更新角色 |
|
||||
| DELETE | `/system/role/:id` | 删除角色 |
|
||||
| GET | `/system/dept/list` | 获取部门树 |
|
||||
| POST | `/system/dept` | 新增部门 |
|
||||
| PUT | `/system/dept/:id` | 更新部门 |
|
||||
| DELETE | `/system/dept/:id` | 删除部门 |
|
||||
| GET | `/timezone/getTimezoneOptions` | 获取时区选项 |
|
||||
| GET | `/timezone/getTimezone` | 获取当前用户时区 |
|
||||
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
||||
| POST | `/upload` | Vben Upload 适配接口,真实上传到 MinIO 并返回 `{ url }` |
|
||||
| GET | `/table/list` | Vben 示例远程表格数据 |
|
||||
| GET | `/status` | Vben 状态码测试接口 |
|
||||
| GET | `/demo/bigint` | Vben BigInt JSON 测试接口 |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| POST | `/auth/login` | 登录,返回 `accessToken` 与 `wordpressAuth`,并写入 access token、刷新 token 和 WordPress 授权 cookie |
|
||||
| POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken,并更新 token cookie |
|
||||
| POST | `/auth/logout` | 退出登录并清理 access token、刷新 token 与 WordPress 授权 cookie |
|
||||
| GET | `/auth/codes` | 获取当前用户权限码 |
|
||||
| GET | `/user/info` | 获取当前用户信息 |
|
||||
| GET | `/menu/all` | 获取当前用户可访问菜单 |
|
||||
| GET | `/system/menu/list` | 获取系统菜单树 |
|
||||
| GET | `/system/menu/name-exists` | 校验菜单 name 是否重复 |
|
||||
| GET | `/system/menu/path-exists` | 校验菜单 path 是否重复 |
|
||||
| POST | `/system/menu` | 新增菜单 |
|
||||
| PUT | `/system/menu/:id` | 更新菜单 |
|
||||
| DELETE | `/system/menu/:id` | 删除菜单及子菜单 |
|
||||
| GET | `/system/role/list` | 分页查询角色 |
|
||||
| POST | `/system/role` | 新增角色 |
|
||||
| PUT | `/system/role/:id` | 更新角色 |
|
||||
| DELETE | `/system/role/:id` | 删除角色 |
|
||||
| GET | `/system/dept/list` | 获取部门树 |
|
||||
| POST | `/system/dept` | 新增部门 |
|
||||
| PUT | `/system/dept/:id` | 更新部门 |
|
||||
| DELETE | `/system/dept/:id` | 删除部门 |
|
||||
| GET | `/timezone/getTimezoneOptions` | 获取时区选项 |
|
||||
| GET | `/timezone/getTimezone` | 获取当前用户时区 |
|
||||
| POST | `/timezone/setTimezone` | 设置当前用户时区 |
|
||||
| POST | `/upload` | Vben Upload 适配接口,真实上传到 MinIO 并返回 `{ url }` |
|
||||
| GET | `/table/list` | Vben 示例远程表格数据 |
|
||||
| GET | `/status` | Vben 状态码测试接口 |
|
||||
| GET | `/demo/bigint` | Vben BigInt JSON 测试接口 |
|
||||
|
||||
初始化 SQL:
|
||||
|
||||
@ -395,24 +399,24 @@ Query:
|
||||
|
||||
### WordPress Article
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/wordpress/article/list` | 获取文章分页列表 |
|
||||
| GET | `/wordpress/article/detail?id=1` | 获取文章详情 |
|
||||
| POST | `/wordpress/article/save` | 新增文章 |
|
||||
| POST | `/wordpress/article/update` | 编辑文章 |
|
||||
| POST | `/wordpress/article/remove?id=1&force=true` | 删除文章 |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ------------------------------------------- | ---------------- |
|
||||
| GET | `/wordpress/article/list` | 获取文章分页列表 |
|
||||
| GET | `/wordpress/article/detail?id=1` | 获取文章详情 |
|
||||
| POST | `/wordpress/article/save` | 新增文章 |
|
||||
| POST | `/wordpress/article/update` | 编辑文章 |
|
||||
| POST | `/wordpress/article/remove?id=1&force=true` | 删除文章 |
|
||||
|
||||
列表 Query:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| pageNo | number | 否 | 页码,默认 `1` |
|
||||
| pageSize | number | 否 | 每页条数,默认 `10` |
|
||||
| search | string | 否 | 关键词搜索 |
|
||||
| status | string | 否 | 文章状态,默认 `any` |
|
||||
| categories | string | 否 | 分类 ID,多个用逗号分隔 |
|
||||
| tags | string | 否 | 标签 ID,多个用逗号分隔 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------- | ------ | ---- | ----------------------- |
|
||||
| pageNo | number | 否 | 页码,默认 `1` |
|
||||
| pageSize | number | 否 | 每页条数,默认 `10` |
|
||||
| search | string | 否 | 关键词搜索 |
|
||||
| status | string | 否 | 文章状态,默认 `any` |
|
||||
| categories | string | 否 | 分类 ID,多个用逗号分隔 |
|
||||
| tags | string | 否 | 标签 ID,多个用逗号分隔 |
|
||||
|
||||
新增/编辑 Body 常用字段:
|
||||
|
||||
@ -433,13 +437,13 @@ Query:
|
||||
|
||||
### WordPress Tag
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/wordpress/tag/list` | 获取标签分页列表 |
|
||||
| GET | `/wordpress/tag/detail?id=1` | 获取标签详情 |
|
||||
| POST | `/wordpress/tag/save` | 新增标签 |
|
||||
| POST | `/wordpress/tag/update` | 编辑标签 |
|
||||
| POST | `/wordpress/tag/remove?id=1&force=true` | 删除标签 |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | --------------------------------------- | ---------------- |
|
||||
| GET | `/wordpress/tag/list` | 获取标签分页列表 |
|
||||
| GET | `/wordpress/tag/detail?id=1` | 获取标签详情 |
|
||||
| POST | `/wordpress/tag/save` | 新增标签 |
|
||||
| POST | `/wordpress/tag/update` | 编辑标签 |
|
||||
| POST | `/wordpress/tag/remove?id=1&force=true` | 删除标签 |
|
||||
|
||||
新增/编辑 Body:
|
||||
|
||||
@ -454,13 +458,13 @@ Query:
|
||||
|
||||
### WordPress Category
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/wordpress/category/list` | 获取分类分页列表 |
|
||||
| GET | `/wordpress/category/detail?id=1` | 获取分类详情 |
|
||||
| POST | `/wordpress/category/save` | 新增分类 |
|
||||
| POST | `/wordpress/category/update` | 编辑分类 |
|
||||
| POST | `/wordpress/category/remove?id=1&force=true` | 删除分类 |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | -------------------------------------------- | ---------------- |
|
||||
| GET | `/wordpress/category/list` | 获取分类分页列表 |
|
||||
| GET | `/wordpress/category/detail?id=1` | 获取分类详情 |
|
||||
| POST | `/wordpress/category/save` | 新增分类 |
|
||||
| POST | `/wordpress/category/update` | 编辑分类 |
|
||||
| POST | `/wordpress/category/remove?id=1&force=true` | 删除分类 |
|
||||
|
||||
新增/编辑 Body:
|
||||
|
||||
|
||||
18
README.md
18
README.md
@ -13,14 +13,14 @@
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 |
|
||||
| --- | --- |
|
||||
| 模块 | 说明 |
|
||||
| ----------- | ----------------------------------------------------------------------------------------- |
|
||||
| `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
|
||||
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
|
||||
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
|
||||
| `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||
| `wordpress` | WordPress 文章、标签、分类管理接口,复用客户端 WordPress 登录态访问 REST API |
|
||||
| `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 |
|
||||
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
|
||||
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
|
||||
| `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
|
||||
| `wordpress` | WordPress 文章、标签、分类管理接口,复用客户端 WordPress 登录态访问 REST API |
|
||||
| `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
@ -57,6 +57,8 @@ WORDPRESS_BASE_URL=http://localhost
|
||||
WORDPRESS_ADMIN_USERNAME=admin
|
||||
WORDPRESS_ADMIN_PASSWORD=
|
||||
WORDPRESS_TIMEOUT_MS=15000
|
||||
WORDPRESS_LOGIN_TIMEOUT_MS=3000
|
||||
WORDPRESS_AVAILABILITY_TTL_MS=60000
|
||||
|
||||
ADMIN_TOKEN_SECRET=change-me
|
||||
ADMIN_COOKIE_SECURE=false
|
||||
@ -113,7 +115,7 @@ pnpm test:e2e # e2e 测试
|
||||
- 如果旧版本曾写入 `admin_user.id=0`,先执行 `sql/fix-admin-user-zero-id.sql` 修复脏数据,再重启服务。
|
||||
- Admin、Component、Dict 与 MinIO 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。
|
||||
- WordPress 管理接口同样先走本系统 `JwtAuthGuard`,再透传客户端 WordPress 登录态访问 WordPress REST API;当前 WordPress 只有单管理员账号且不开放注册,账号配置放在 env 中,但不作为 BasicAuth 发送。
|
||||
- Admin 前端只调用现有 `/auth/login`;后端会在登录流程里自动登录 WordPress,把 WordPress cookie 写入本系统 httpOnly cookie,前端只持久化 REST nonce 和用户信息。
|
||||
- Admin 前端只调用现有 `/auth/login`;后端会在登录流程里自动尝试登录 WordPress,把 WordPress cookie 写入本系统 httpOnly cookie,前端只持久化 REST nonce 和用户信息。WordPress 远程不可用时不会阻塞 Admin 主登录,后端会返回 `wordpressAuth=null` 并在菜单和按钮权限接口中过滤博客管理相关入口。
|
||||
- WordPress 客户端登录态优先通过 `X-WordPress-Authorization` 透传,也支持 `X-WP-Nonce` 加 WordPress 登录 cookie 的 REST cookie 认证。
|
||||
- 如果 WordPress 服务器未开启 rewrite 导致 `/wp-json/*` 返回 404,后端会自动回退到 `?rest_route=/...` 形式继续访问 REST API。
|
||||
- `kt-template-admin` 登录会写入 access token 与刷新 token cookie,`kt-template-online-web` 和 `kt-template-online-playground` 可在回跳后通过刷新 token 重新持久化登录态。
|
||||
|
||||
@ -137,6 +137,19 @@ VALUES
|
||||
(2041700000000120201, 2041700000000100202, 'SystemDeptCreate', NULL, NULL, NULL, 'System:Dept:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120202, 2041700000000100202, 'SystemDeptEdit', NULL, NULL, NULL, 'System:Dept:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120203, 2041700000000100202, 'SystemDeptDelete', NULL, NULL, NULL, 'System:Dept:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100),
|
||||
(2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0),
|
||||
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
|
||||
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120313, 2041700000000100302, 'BlogCategoryDelete', NULL, NULL, NULL, 'Blog:Category:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000100303, 2041700000000100300, 'BlogTag', '/blog/tag', '/blog/tag/list', NULL, 'Blog:Tag:List', 'menu', '{"icon":"lucide:tags","title":"标签管理"}', 1, 2),
|
||||
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||
(2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998),
|
||||
(2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0),
|
||||
(2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0),
|
||||
|
||||
@ -39,18 +39,26 @@ export class AdminAuthController {
|
||||
body.password,
|
||||
);
|
||||
const wordpressLogin =
|
||||
await this.wordpressService.loginWithConfiguredAdmin();
|
||||
await this.wordpressService.tryLoginWithConfiguredAdmin();
|
||||
this.authService.setAccessTokenCookie(res, accessToken);
|
||||
this.authService.setRefreshTokenCookie(res, refreshToken);
|
||||
this.wordpressService.setAuthCookie(res, wordpressLogin.cookie);
|
||||
if (wordpressLogin.available) {
|
||||
this.wordpressService.setAuthCookie(res, wordpressLogin.result.cookie);
|
||||
} else {
|
||||
this.wordpressService.clearAuthCookie(res);
|
||||
}
|
||||
|
||||
return vbenSuccess({
|
||||
...this.userService.serializeUser(user),
|
||||
accessToken,
|
||||
wordpressAuth: {
|
||||
...wordpressLogin.auth,
|
||||
user: wordpressLogin.user,
|
||||
},
|
||||
wordpressAuth: wordpressLogin.available
|
||||
? {
|
||||
...wordpressLogin.result.auth,
|
||||
user: wordpressLogin.result.user,
|
||||
}
|
||||
: null,
|
||||
wordpressAvailable: wordpressLogin.available,
|
||||
wordpressError: wordpressLogin.error,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { toTree } from '@/common';
|
||||
import { WordpressService } from '@/wordpress/wordpress.service';
|
||||
import { AdminUser } from '../user/admin-user.entity';
|
||||
import { AdminMenu, AdminMenuMeta } from './admin-menu.entity';
|
||||
|
||||
@ -15,17 +16,20 @@ export class AdminMenuService {
|
||||
constructor(
|
||||
@InjectRepository(AdminMenu)
|
||||
private readonly menuRepository: Repository<AdminMenu>,
|
||||
private readonly wordpressService: WordpressService,
|
||||
) {}
|
||||
|
||||
async getAccessCodes(user: AdminUser) {
|
||||
const menus = await this.getAllowedMenus(user);
|
||||
return menus
|
||||
.map((menu) => menu.authCode)
|
||||
.filter((authCode) => !!authCode);
|
||||
const menus = this.filterUnavailableFeatureMenus(
|
||||
await this.getAllowedMenus(user),
|
||||
);
|
||||
return menus.map((menu) => menu.authCode).filter((authCode) => !!authCode);
|
||||
}
|
||||
|
||||
async getRouteMenus(user: AdminUser) {
|
||||
const menus = await this.getAllowedMenus(user);
|
||||
const menus = this.filterUnavailableFeatureMenus(
|
||||
await this.getAllowedMenus(user),
|
||||
);
|
||||
return this.buildMenuTree(menus.filter((menu) => menu.type !== 'button'));
|
||||
}
|
||||
|
||||
@ -114,6 +118,22 @@ export class AdminMenuService {
|
||||
return [...menuMap.values()];
|
||||
}
|
||||
|
||||
private filterUnavailableFeatureMenus(menus: AdminMenu[]) {
|
||||
if (!menus.some((menu) => this.isWordpressMenu(menu))) return menus;
|
||||
if (this.wordpressService.isAdminIntegrationAvailable()) return menus;
|
||||
|
||||
// WordPress 是外部增强能力,远程不可用时不影响 Admin 主系统登录和系统管理菜单。
|
||||
return menus.filter((menu) => !this.isWordpressMenu(menu));
|
||||
}
|
||||
|
||||
private isWordpressMenu(menu: AdminMenu) {
|
||||
return (
|
||||
menu.name?.startsWith('Blog') ||
|
||||
menu.path?.startsWith('/blog') ||
|
||||
menu.authCode?.startsWith('Blog:')
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeMenuInput(
|
||||
data: MenuInput,
|
||||
includeEmptyMeta: boolean,
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||
import { ToolsService } from '@/common';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import {
|
||||
WordpressArticleBodyDto,
|
||||
WordpressArticleListQueryDto,
|
||||
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
|
||||
@Controller('wordpress/article')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WordpressArticleController {
|
||||
constructor(
|
||||
private readonly toolsService: ToolsService,
|
||||
private readonly wordpressService: WordpressService,
|
||||
) {}
|
||||
constructor(private readonly wordpressService: WordpressService) {}
|
||||
|
||||
@Get('list')
|
||||
@ApiOperation({ summary: '获取 WordPress 文章分页列表' })
|
||||
@ -50,7 +47,7 @@ export class WordpressArticleController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const list = await this.wordpressService.articleList(query, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
|
||||
return res.send(vbenSuccess(list));
|
||||
}
|
||||
|
||||
@Get('detail')
|
||||
@ -60,7 +57,7 @@ export class WordpressArticleController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const detail = await this.wordpressService.articleDetail(id, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
|
||||
return res.send(vbenSuccess(detail));
|
||||
}
|
||||
|
||||
@Post('save')
|
||||
@ -74,7 +71,7 @@ export class WordpressArticleController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.articleSave(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('update')
|
||||
@ -88,7 +85,7 @@ export class WordpressArticleController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.articleUpdate(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('remove')
|
||||
@ -109,6 +106,6 @@ export class WordpressArticleController {
|
||||
auth,
|
||||
);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
@ -10,7 +9,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||
import { Public, ToolsService } from '@/common';
|
||||
import { Public, vbenSuccess } from '@/common';
|
||||
import { WordpressService } from './wordpress.service';
|
||||
|
||||
@ApiTags('wordpress-auth')
|
||||
@ -27,10 +26,7 @@ import { WordpressService } from './wordpress.service';
|
||||
@Controller('wordpress/auth')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WordpressAuthController {
|
||||
constructor(
|
||||
private readonly toolsService: ToolsService,
|
||||
private readonly wordpressService: WordpressService,
|
||||
) {}
|
||||
constructor(private readonly wordpressService: WordpressService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '使用环境变量中的 WordPress 管理员账号自动认证' })
|
||||
@ -39,7 +35,7 @@ export class WordpressAuthController {
|
||||
await this.wordpressService.loginWithConfiguredAdmin();
|
||||
this.wordpressService.setAuthCookie(res, cookie);
|
||||
|
||||
return this.toolsService.res(HttpStatus.OK, '操作成功', {
|
||||
return vbenSuccess({
|
||||
auth,
|
||||
user,
|
||||
});
|
||||
@ -51,7 +47,7 @@ export class WordpressAuthController {
|
||||
logout(@Res({ passthrough: true }) res: Response) {
|
||||
this.wordpressService.clearAuthCookie(res);
|
||||
|
||||
return this.toolsService.res(HttpStatus.OK, '操作成功', true);
|
||||
return vbenSuccess(true);
|
||||
}
|
||||
|
||||
@Get('check')
|
||||
@ -60,6 +56,6 @@ export class WordpressAuthController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const user = await this.wordpressService.checkAuth(auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', user));
|
||||
return res.send(vbenSuccess(user));
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||
import { ToolsService } from '@/common';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import {
|
||||
WordpressTermBodyDto,
|
||||
WordpressTermListQueryDto,
|
||||
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
|
||||
@Controller('wordpress/category')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WordpressCategoryController {
|
||||
constructor(
|
||||
private readonly toolsService: ToolsService,
|
||||
private readonly wordpressService: WordpressService,
|
||||
) {}
|
||||
constructor(private readonly wordpressService: WordpressService) {}
|
||||
|
||||
@Get('list')
|
||||
@ApiOperation({ summary: '获取 WordPress 分类分页列表' })
|
||||
@ -50,7 +47,7 @@ export class WordpressCategoryController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const list = await this.wordpressService.categoryList(query, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
|
||||
return res.send(vbenSuccess(list));
|
||||
}
|
||||
|
||||
@Get('detail')
|
||||
@ -60,7 +57,7 @@ export class WordpressCategoryController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const detail = await this.wordpressService.categoryDetail(id, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
|
||||
return res.send(vbenSuccess(detail));
|
||||
}
|
||||
|
||||
@Post('save')
|
||||
@ -74,7 +71,7 @@ export class WordpressCategoryController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.categorySave(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('update')
|
||||
@ -88,7 +85,7 @@ export class WordpressCategoryController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.categoryUpdate(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('remove')
|
||||
@ -109,6 +106,6 @@ export class WordpressCategoryController {
|
||||
auth,
|
||||
);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||
import { ToolsService } from '@/common';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import {
|
||||
WordpressTermBodyDto,
|
||||
WordpressTermListQueryDto,
|
||||
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
|
||||
@Controller('wordpress/tag')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WordpressTagController {
|
||||
constructor(
|
||||
private readonly toolsService: ToolsService,
|
||||
private readonly wordpressService: WordpressService,
|
||||
) {}
|
||||
constructor(private readonly wordpressService: WordpressService) {}
|
||||
|
||||
@Get('list')
|
||||
@ApiOperation({ summary: '获取 WordPress 标签分页列表' })
|
||||
@ -50,7 +47,7 @@ export class WordpressTagController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const list = await this.wordpressService.tagList(query, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
|
||||
return res.send(vbenSuccess(list));
|
||||
}
|
||||
|
||||
@Get('detail')
|
||||
@ -60,7 +57,7 @@ export class WordpressTagController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const detail = await this.wordpressService.tagDetail(id, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
|
||||
return res.send(vbenSuccess(detail));
|
||||
}
|
||||
|
||||
@Post('save')
|
||||
@ -74,7 +71,7 @@ export class WordpressTagController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.tagSave(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('update')
|
||||
@ -88,7 +85,7 @@ export class WordpressTagController {
|
||||
const auth = this.wordpressService.getAuthContext(req);
|
||||
const result = await this.wordpressService.tagUpdate(body, auth);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
|
||||
@Post('remove')
|
||||
@ -109,6 +106,6 @@ export class WordpressTagController {
|
||||
auth,
|
||||
);
|
||||
|
||||
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
|
||||
return res.send(vbenSuccess(result));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
|
||||
import { ToolsService } from '@/common';
|
||||
import { WordpressArticleController } from './wordpress-article.controller';
|
||||
import { WordpressAuthController } from './wordpress-auth.controller';
|
||||
import { WordpressCategoryController } from './wordpress-category.controller';
|
||||
@ -15,7 +14,7 @@ import { WordpressTagController } from './wordpress-tag.controller';
|
||||
WordpressTagController,
|
||||
WordpressCategoryController,
|
||||
],
|
||||
providers: [ToolsService, WordpressService],
|
||||
providers: [WordpressService],
|
||||
exports: [WordpressService],
|
||||
})
|
||||
export class WordpressModule {}
|
||||
|
||||
@ -23,6 +23,30 @@ export type WordpressLoginResult = {
|
||||
user: any;
|
||||
};
|
||||
|
||||
export type WordpressOptionalLoginResult =
|
||||
| {
|
||||
available: false;
|
||||
error: WordpressAvailabilityError;
|
||||
result: null;
|
||||
}
|
||||
| {
|
||||
available: true;
|
||||
error: null;
|
||||
result: WordpressLoginResult & { cookie: string };
|
||||
};
|
||||
|
||||
export type WordpressAvailabilityError = {
|
||||
error: any;
|
||||
message: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type WordpressAvailabilityCache = {
|
||||
available: boolean;
|
||||
checkedAt: number;
|
||||
error?: WordpressAvailabilityError;
|
||||
};
|
||||
|
||||
type WordpressRequestOptions = {
|
||||
auth: WordpressAuthContext;
|
||||
body?: Record<string, unknown>;
|
||||
@ -46,6 +70,8 @@ const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth';
|
||||
|
||||
@Injectable()
|
||||
export class WordpressService {
|
||||
private availabilityCache: null | WordpressAvailabilityCache = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
getAuthContext(request: Request): WordpressAuthContext {
|
||||
@ -79,9 +105,45 @@ export class WordpressService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async loginWithConfiguredAdmin(): Promise<
|
||||
WordpressLoginResult & { cookie: string }
|
||||
> {
|
||||
async tryLoginWithConfiguredAdmin(): Promise<WordpressOptionalLoginResult> {
|
||||
try {
|
||||
const result = await this.loginWithConfiguredAdmin({
|
||||
timeoutMs: this.getLoginTimeout(),
|
||||
});
|
||||
this.rememberAvailability(true);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
error: null,
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = this.normalizeAvailabilityError(err);
|
||||
this.rememberAvailability(false, error);
|
||||
|
||||
return {
|
||||
available: false,
|
||||
error,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
isAdminIntegrationAvailable() {
|
||||
if (!this.availabilityCache) return true;
|
||||
if (
|
||||
Date.now() - this.availabilityCache.checkedAt >
|
||||
this.getAvailabilityTtl()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.availabilityCache.available;
|
||||
}
|
||||
|
||||
async loginWithConfiguredAdmin(
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<WordpressLoginResult & { cookie: string }> {
|
||||
const username = this.configService.get<string>('WORDPRESS_ADMIN_USERNAME');
|
||||
const password = this.configService.get<string>('WORDPRESS_ADMIN_PASSWORD');
|
||||
|
||||
@ -93,8 +155,12 @@ export class WordpressService {
|
||||
);
|
||||
}
|
||||
|
||||
const cookie = await this.loginByPassword(username, password);
|
||||
const nonce = await this.fetchRestNonce(cookie);
|
||||
const cookie = await this.loginByPassword(
|
||||
username,
|
||||
password,
|
||||
options.timeoutMs,
|
||||
);
|
||||
const nonce = await this.fetchRestNonce(cookie, options.timeoutMs);
|
||||
|
||||
if (!nonce) {
|
||||
throwVbenError(
|
||||
@ -138,7 +204,10 @@ export class WordpressService {
|
||||
});
|
||||
}
|
||||
|
||||
async articleList(query: WordpressArticleListQueryDto, auth: WordpressAuthContext) {
|
||||
async articleList(
|
||||
query: WordpressArticleListQueryDto,
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
const response = await this.request<any[]>('/wp-json/wp/v2/posts', {
|
||||
auth,
|
||||
query: {
|
||||
@ -179,7 +248,10 @@ export class WordpressService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async articleUpdate(body: WordpressArticleBodyDto & { id: number }, auth: WordpressAuthContext) {
|
||||
async articleUpdate(
|
||||
body: WordpressArticleBodyDto & { id: number },
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
|
||||
auth,
|
||||
body: this.getArticleBody(body),
|
||||
@ -189,7 +261,11 @@ export class WordpressService {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async articleRemove(id: string | number, force: boolean, auth: WordpressAuthContext) {
|
||||
async articleRemove(
|
||||
id: string | number,
|
||||
force: boolean,
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
const response = await this.request(`/wp-json/wp/v2/posts/${id}`, {
|
||||
auth,
|
||||
method: 'DELETE',
|
||||
@ -213,15 +289,25 @@ export class WordpressService {
|
||||
return this.termSave('/wp-json/wp/v2/tags', body, auth);
|
||||
}
|
||||
|
||||
async tagUpdate(body: WordpressTermBodyDto & { id: number }, auth: WordpressAuthContext) {
|
||||
async tagUpdate(
|
||||
body: WordpressTermBodyDto & { id: number },
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
return this.termUpdate('/wp-json/wp/v2/tags', body, auth);
|
||||
}
|
||||
|
||||
async tagRemove(id: string | number, force: boolean, auth: WordpressAuthContext) {
|
||||
async tagRemove(
|
||||
id: string | number,
|
||||
force: boolean,
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
return this.termRemove('/wp-json/wp/v2/tags', id, force, auth);
|
||||
}
|
||||
|
||||
async categoryList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
|
||||
async categoryList(
|
||||
query: WordpressTermListQueryDto,
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
return this.termList('/wp-json/wp/v2/categories', query, auth);
|
||||
}
|
||||
|
||||
@ -233,11 +319,18 @@ export class WordpressService {
|
||||
return this.termSave('/wp-json/wp/v2/categories', body, auth);
|
||||
}
|
||||
|
||||
async categoryUpdate(body: WordpressTermBodyDto & { id: number }, auth: WordpressAuthContext) {
|
||||
async categoryUpdate(
|
||||
body: WordpressTermBodyDto & { id: number },
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
return this.termUpdate('/wp-json/wp/v2/categories', body, auth);
|
||||
}
|
||||
|
||||
async categoryRemove(id: string | number, force: boolean, auth: WordpressAuthContext) {
|
||||
async categoryRemove(
|
||||
id: string | number,
|
||||
force: boolean,
|
||||
auth: WordpressAuthContext,
|
||||
) {
|
||||
return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
|
||||
}
|
||||
|
||||
@ -346,7 +439,11 @@ export class WordpressService {
|
||||
const data = await this.parseResponse(response);
|
||||
|
||||
// 兼容未开启 Apache rewrite 的 WordPress:/wp-json 404 时自动回退到 ?rest_route=。
|
||||
if (!response.ok && response.status === 404 && index < urls.length - 1) {
|
||||
if (
|
||||
!response.ok &&
|
||||
response.status === 404 &&
|
||||
index < urls.length - 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -380,14 +477,22 @@ export class WordpressService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loginByPassword(username: string, password: string) {
|
||||
const response = await this.formRequest('/wp-login.php', {
|
||||
log: username,
|
||||
pwd: password,
|
||||
redirect_to: this.getUrl('/wp-admin/'),
|
||||
testcookie: '1',
|
||||
'wp-submit': 'Log In',
|
||||
});
|
||||
private async loginByPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
const response = await this.formRequest(
|
||||
'/wp-login.php',
|
||||
{
|
||||
log: username,
|
||||
pwd: password,
|
||||
redirect_to: this.getUrl('/wp-admin/'),
|
||||
testcookie: '1',
|
||||
'wp-submit': 'Log In',
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
const setCookies = this.getSetCookieHeaders(response.headers);
|
||||
const cookie = this.toCookieHeader(setCookies);
|
||||
|
||||
@ -403,15 +508,23 @@ export class WordpressService {
|
||||
return cookie;
|
||||
}
|
||||
|
||||
private async fetchRestNonce(cookie: string) {
|
||||
const adminPaths = ['/wp-admin/', '/wp-admin/post-new.php', '/wp-admin/edit.php'];
|
||||
private async fetchRestNonce(cookie: string, timeoutMs?: number) {
|
||||
const adminPaths = [
|
||||
'/wp-admin/',
|
||||
'/wp-admin/post-new.php',
|
||||
'/wp-admin/edit.php',
|
||||
];
|
||||
|
||||
for (const path of adminPaths) {
|
||||
const response = await this.rawRequest(path, {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
const response = await this.rawRequest(
|
||||
path,
|
||||
{
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
});
|
||||
timeoutMs,
|
||||
);
|
||||
const html = await response.text().catch(() => '');
|
||||
const nonce = this.extractRestNonce(html);
|
||||
|
||||
@ -421,23 +534,38 @@ export class WordpressService {
|
||||
return '';
|
||||
}
|
||||
|
||||
private async formRequest(path: string, body: Record<string, string>) {
|
||||
private async formRequest(
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
const form = new URLSearchParams(body);
|
||||
|
||||
return this.rawRequest(path, {
|
||||
body: form,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: 'wordpress_test_cookie=WP Cookie check',
|
||||
return this.rawRequest(
|
||||
path,
|
||||
{
|
||||
body: form,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: 'wordpress_test_cookie=WP Cookie check',
|
||||
},
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
},
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
});
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
private async rawRequest(path: string, init: RequestInit = {}) {
|
||||
private async rawRequest(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.getTimeout());
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
timeoutMs || this.getTimeout(),
|
||||
);
|
||||
|
||||
try {
|
||||
return await fetch(this.getUrl(path), {
|
||||
@ -497,7 +625,8 @@ export class WordpressService {
|
||||
}
|
||||
|
||||
private getCookieOptions() {
|
||||
const secure = this.configService.get<string>('ADMIN_COOKIE_SECURE') === 'true';
|
||||
const secure =
|
||||
this.configService.get<string>('ADMIN_COOKIE_SECURE') === 'true';
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
@ -551,6 +680,31 @@ export class WordpressService {
|
||||
return Number(this.configService.get('WORDPRESS_TIMEOUT_MS') || 15000);
|
||||
}
|
||||
|
||||
private getLoginTimeout() {
|
||||
return Number(
|
||||
this.configService.get('WORDPRESS_LOGIN_TIMEOUT_MS') ||
|
||||
this.configService.get('WORDPRESS_TIMEOUT_MS') ||
|
||||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
private getAvailabilityTtl() {
|
||||
return Number(
|
||||
this.configService.get('WORDPRESS_AVAILABILITY_TTL_MS') || 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
private rememberAvailability(
|
||||
available: boolean,
|
||||
error?: WordpressAvailabilityError,
|
||||
) {
|
||||
this.availabilityCache = {
|
||||
available,
|
||||
checkedAt: Date.now(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
private getPageQuery(query: WordpressPagedQueryDto) {
|
||||
return {
|
||||
order: query.order,
|
||||
@ -642,6 +796,30 @@ export class WordpressService {
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeAvailabilityError(err: unknown): WordpressAvailabilityError {
|
||||
if (err instanceof HttpException) {
|
||||
const response = err.getResponse();
|
||||
const responseBody =
|
||||
response && typeof response === 'object'
|
||||
? (response as Record<string, any>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
error: responseBody.error || response,
|
||||
message:
|
||||
responseBody.message ||
|
||||
(typeof response === 'string' ? response : err.message),
|
||||
status: err.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: err instanceof Error ? err.name : 'WordPressUnavailable',
|
||||
message: err instanceof Error ? err.message : 'WordPress 暂不可用',
|
||||
status: HttpStatus.BAD_GATEWAY,
|
||||
};
|
||||
}
|
||||
|
||||
private getErrorCause(err: unknown) {
|
||||
const cause = (err as { cause?: { code?: string; message?: string } })
|
||||
?.cause;
|
||||
@ -650,11 +828,16 @@ export class WordpressService {
|
||||
}
|
||||
|
||||
private getLoginErrorMessage(html: string) {
|
||||
const match = html.match(/<div[^>]*id=["']login_error["'][^>]*>([\s\S]*?)<\/div>/i);
|
||||
const match = html.match(
|
||||
/<div[^>]*id=["']login_error["'][^>]*>([\s\S]*?)<\/div>/i,
|
||||
);
|
||||
|
||||
if (!match?.[1]) return 'WordPress 管理员登录失败';
|
||||
|
||||
return match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
return match[1]
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private getSetCookieHeaders(headers: Headers) {
|
||||
@ -778,4 +961,6 @@ export class WordpressService {
|
||||
}
|
||||
}
|
||||
|
||||
type WordpressPagedQueryDto = WordpressArticleListQueryDto | WordpressTermListQueryDto;
|
||||
type WordpressPagedQueryDto =
|
||||
| WordpressArticleListQueryDto
|
||||
| WordpressTermListQueryDto;
|
||||
|
||||
@ -563,9 +563,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
expect(wordpressServiceMock.checkAuth).toHaveBeenCalledWith(
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressUser,
|
||||
});
|
||||
},
|
||||
@ -584,9 +583,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
expect.anything(),
|
||||
wordpressLoginResult.cookie,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: {
|
||||
auth: wordpressLoginResult.auth,
|
||||
user: wordpressUser,
|
||||
@ -602,9 +600,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
expect(wordpressServiceMock.clearAuthCookie).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: true,
|
||||
});
|
||||
},
|
||||
@ -632,9 +629,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: {
|
||||
list: [wordpressArticle],
|
||||
total: 1,
|
||||
@ -654,9 +650,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
'1',
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressArticle,
|
||||
});
|
||||
},
|
||||
@ -680,9 +675,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressArticle,
|
||||
});
|
||||
},
|
||||
@ -705,9 +699,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressArticle,
|
||||
});
|
||||
},
|
||||
@ -728,9 +721,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
false,
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: true,
|
||||
});
|
||||
},
|
||||
@ -758,9 +750,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: {
|
||||
list: [wordpressTerm],
|
||||
total: 1,
|
||||
@ -780,9 +771,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
'1',
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -804,9 +794,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -829,9 +818,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -849,9 +837,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
true,
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: true,
|
||||
});
|
||||
},
|
||||
@ -879,9 +866,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: {
|
||||
list: [wordpressTerm],
|
||||
total: 1,
|
||||
@ -901,9 +887,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
'1',
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -927,9 +912,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -954,9 +938,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
},
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: wordpressTerm,
|
||||
});
|
||||
},
|
||||
@ -974,9 +957,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
true,
|
||||
wordpressAuthContext,
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
code: 200,
|
||||
msg: '操作成功',
|
||||
expect(response.body).toMatchObject({
|
||||
code: 0,
|
||||
data: true,
|
||||
});
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user