fix(api): 优化WordPress降级和后台权限

This commit is contained in:
sunlei 2026-05-18 20:05:10 +08:00
parent 755103f403
commit 74dad7d70d
13 changed files with 451 additions and 249 deletions

View File

@ -15,6 +15,8 @@ WORDPRESS_BASE_URL=http://localhost
WORDPRESS_ADMIN_USERNAME=admin WORDPRESS_ADMIN_USERNAME=admin
WORDPRESS_ADMIN_PASSWORD= WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000 WORDPRESS_TIMEOUT_MS=15000
WORDPRESS_LOGIN_TIMEOUT_MS=3000
WORDPRESS_AVAILABILITY_TTL_MS=60000
ADMIN_TOKEN_SECRET=change-me ADMIN_TOKEN_SECRET=change-me
ADMIN_COOKIE_SECURE=false ADMIN_COOKIE_SECURE=false

24
API.md
View File

@ -25,7 +25,7 @@
## 功能模块 ## 功能模块
| 模块 | 说明 | | 模块 | 说明 |
| --------- | --------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------------------------- |
| Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` | | Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 | | Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 | | Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
@ -62,16 +62,18 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
环境变量: 环境变量:
| 变量 | 说明 | | 变量 | 说明 |
| --- | --- | | ------------------------------- | ------------------------------------------------------------------------------------------ |
| `WORDPRESS_BASE_URL` | WordPress 站点根地址,例如 `http://192.168.31.224:8080` | | `WORDPRESS_BASE_URL` | WordPress 站点根地址,例如 `http://192.168.31.224:8080` |
| `WORDPRESS_ADMIN_USERNAME` | WordPress 单管理员账号用户名 | | `WORDPRESS_ADMIN_USERNAME` | WordPress 单管理员账号用户名 |
| `WORDPRESS_ADMIN_PASSWORD` | WordPress 单管理员账号密码,仅放真实 env不提交到仓库 | | `WORDPRESS_ADMIN_PASSWORD` | WordPress 单管理员账号密码,仅放真实 env不提交到仓库 |
| `WORDPRESS_TIMEOUT_MS` | WordPress REST API 请求超时时间,默认 `15000` | | `WORDPRESS_TIMEOUT_MS` | WordPress REST API 请求超时时间,默认 `15000` |
| `WORDPRESS_LOGIN_TIMEOUT_MS` | Admin 登录链路里 WordPress 自动认证的短超时时间,默认 `3000`,避免远程不可用阻塞主系统登录 |
| `WORDPRESS_AVAILABILITY_TTL_MS` | WordPress 可用性缓存时间,默认 `60000`;远程不可用时用于过滤博客菜单和按钮权限 |
支持的 WordPress 登录态来源: 支持的 WordPress 登录态来源:
| Header/Cookie | 说明 | | Header/Cookie | 说明 |
| --- | --- | | --------------------------- | --------------------------------------------------------------------------------------------------- |
| `X-WordPress-Authorization` | 优先透传的 WordPress 授权头,例如客户端登录拿到的 `Bearer <token>` | | `X-WordPress-Authorization` | 优先透传的 WordPress 授权头,例如客户端登录拿到的 `Bearer <token>` |
| `Authorization` | 仅当它不是本系统 Admin access token 时才会透传,避免和后台认证冲突 | | `Authorization` | 仅当它不是本系统 Admin access token 时才会透传,避免和后台认证冲突 |
| `X-WP-Nonce` | WordPress REST cookie 认证 nonce | | `X-WP-Nonce` | WordPress REST cookie 认证 nonce |
@ -81,6 +83,8 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
如果 WordPress 所在 Apache/Nginx 未开启 rewrite`/wp-json/*` 可能返回 404。后端会自动回退到 WordPress 原生 `?rest_route=/...` 形式,避免因为固定链接配置阻断文章、标签和分类管理接口。 如果 WordPress 所在 Apache/Nginx 未开启 rewrite`/wp-json/*` 可能返回 404。后端会自动回退到 WordPress 原生 `?rest_route=/...` 形式,避免因为固定链接配置阻断文章、标签和分类管理接口。
Admin 主登录不依赖 WordPress 可用性:本系统账号验证通过后会先写入 Admin tokenWordPress 自动认证失败时登录仍返回成功,`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_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容,但仍需要登录态。
@ -88,7 +92,7 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
`admin_dict` 表核心字段: `admin_dict` 表核心字段:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
| ----------- | ------- | ------------------------------------------------------ | | ------------ | ------- | ------------------------------------------------------ |
| id | string | 字典数字 ID | | id | string | 字典数字 ID |
| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` | | dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
| label | string | 展示文本 | | label | string | 展示文本 |
@ -101,7 +105,7 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
当前数据库示例关系: 当前数据库示例关系:
| dictCode | value | label | childrenCode | | dictCode | value | label | childrenCode |
| -------------- | ----- | ----- | ----------- | | -------------- | ----- | ----- | ------------ |
| COMPONENT_TYPE | 1 | 图表 | CHART | | COMPONENT_TYPE | 1 | 图表 | CHART |
| COMPONENT_TYPE | 2 | 组件 | COMPONENT | | COMPONENT_TYPE | 2 | 组件 | COMPONENT |
@ -324,7 +328,7 @@ Query
核心接口: 核心接口:
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | ------ | ------------------------------ | ----------------------------------------------------------------------------------------------------- |
| POST | `/auth/login` | 登录,返回 `accessToken``wordpressAuth`,并写入 access token、刷新 token 和 WordPress 授权 cookie | | POST | `/auth/login` | 登录,返回 `accessToken``wordpressAuth`,并写入 access token、刷新 token 和 WordPress 授权 cookie |
| POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken并更新 token cookie | | POST | `/auth/refresh` | 通过刷新 token cookie 刷新 accessToken并更新 token cookie |
| POST | `/auth/logout` | 退出登录并清理 access token、刷新 token 与 WordPress 授权 cookie | | POST | `/auth/logout` | 退出登录并清理 access token、刷新 token 与 WordPress 授权 cookie |
@ -396,7 +400,7 @@ Query
### WordPress Article ### WordPress Article
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | ---- | ------------------------------------------- | ---------------- |
| GET | `/wordpress/article/list` | 获取文章分页列表 | | GET | `/wordpress/article/list` | 获取文章分页列表 |
| GET | `/wordpress/article/detail?id=1` | 获取文章详情 | | GET | `/wordpress/article/detail?id=1` | 获取文章详情 |
| POST | `/wordpress/article/save` | 新增文章 | | POST | `/wordpress/article/save` | 新增文章 |
@ -406,7 +410,7 @@ Query
列表 Query 列表 Query
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | ---------- | ------ | ---- | ----------------------- |
| pageNo | number | 否 | 页码,默认 `1` | | pageNo | number | 否 | 页码,默认 `1` |
| pageSize | number | 否 | 每页条数,默认 `10` | | pageSize | number | 否 | 每页条数,默认 `10` |
| search | string | 否 | 关键词搜索 | | search | string | 否 | 关键词搜索 |
@ -434,7 +438,7 @@ Query
### WordPress Tag ### WordPress Tag
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | ---- | --------------------------------------- | ---------------- |
| GET | `/wordpress/tag/list` | 获取标签分页列表 | | GET | `/wordpress/tag/list` | 获取标签分页列表 |
| GET | `/wordpress/tag/detail?id=1` | 获取标签详情 | | GET | `/wordpress/tag/detail?id=1` | 获取标签详情 |
| POST | `/wordpress/tag/save` | 新增标签 | | POST | `/wordpress/tag/save` | 新增标签 |
@ -455,7 +459,7 @@ Query
### WordPress Category ### WordPress Category
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | ---- | -------------------------------------------- | ---------------- |
| GET | `/wordpress/category/list` | 获取分类分页列表 | | GET | `/wordpress/category/list` | 获取分类分页列表 |
| GET | `/wordpress/category/detail?id=1` | 获取分类详情 | | GET | `/wordpress/category/detail?id=1` | 获取分类详情 |
| POST | `/wordpress/category/save` | 新增分类 | | POST | `/wordpress/category/save` | 新增分类 |

View File

@ -14,7 +14,7 @@
## 功能模块 ## 功能模块
| 模块 | 说明 | | 模块 | 说明 |
| --- | --- | | ----------- | ----------------------------------------------------------------------------------------- |
| `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` | | `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 | | `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 | | `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
@ -57,6 +57,8 @@ WORDPRESS_BASE_URL=http://localhost
WORDPRESS_ADMIN_USERNAME=admin WORDPRESS_ADMIN_USERNAME=admin
WORDPRESS_ADMIN_PASSWORD= WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000 WORDPRESS_TIMEOUT_MS=15000
WORDPRESS_LOGIN_TIMEOUT_MS=3000
WORDPRESS_AVAILABILITY_TTL_MS=60000
ADMIN_TOKEN_SECRET=change-me ADMIN_TOKEN_SECRET=change-me
ADMIN_COOKIE_SECURE=false ADMIN_COOKIE_SECURE=false
@ -113,7 +115,7 @@ pnpm test:e2e # e2e 测试
- 如果旧版本曾写入 `admin_user.id=0`,先执行 `sql/fix-admin-user-zero-id.sql` 修复脏数据,再重启服务。 - 如果旧版本曾写入 `admin_user.id=0`,先执行 `sql/fix-admin-user-zero-id.sql` 修复脏数据,再重启服务。
- Admin、Component、Dict 与 MinIO 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。 - Admin、Component、Dict 与 MinIO 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。
- WordPress 管理接口同样先走本系统 `JwtAuthGuard`,再透传客户端 WordPress 登录态访问 WordPress REST API当前 WordPress 只有单管理员账号且不开放注册,账号配置放在 env 中,但不作为 BasicAuth 发送。 - 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 客户端登录态优先通过 `X-WordPress-Authorization` 透传,也支持 `X-WP-Nonce` 加 WordPress 登录 cookie 的 REST cookie 认证。
- 如果 WordPress 服务器未开启 rewrite 导致 `/wp-json/*` 返回 404后端会自动回退到 `?rest_route=/...` 形式继续访问 REST API。 - 如果 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 重新持久化登录态。 - `kt-template-admin` 登录会写入 access token 与刷新 token cookie`kt-template-online-web` 和 `kt-template-online-playground` 可在回跳后通过刷新 token 重新持久化登录态。

View File

@ -137,6 +137,19 @@ VALUES
(2041700000000120201, 2041700000000100202, 'SystemDeptCreate', NULL, NULL, NULL, 'System:Dept:Create', 'button', '{"title":"common.create"}', 1, 0), (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), (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), (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), (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), (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), (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),

View File

@ -39,18 +39,26 @@ export class AdminAuthController {
body.password, body.password,
); );
const wordpressLogin = const wordpressLogin =
await this.wordpressService.loginWithConfiguredAdmin(); await this.wordpressService.tryLoginWithConfiguredAdmin();
this.authService.setAccessTokenCookie(res, accessToken); this.authService.setAccessTokenCookie(res, accessToken);
this.authService.setRefreshTokenCookie(res, refreshToken); 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({ return vbenSuccess({
...this.userService.serializeUser(user), ...this.userService.serializeUser(user),
accessToken, accessToken,
wordpressAuth: { wordpressAuth: wordpressLogin.available
...wordpressLogin.auth, ? {
user: wordpressLogin.user, ...wordpressLogin.result.auth,
}, user: wordpressLogin.result.user,
}
: null,
wordpressAvailable: wordpressLogin.available,
wordpressError: wordpressLogin.error,
}); });
} }

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { toTree } from '@/common'; import { toTree } from '@/common';
import { WordpressService } from '@/wordpress/wordpress.service';
import { AdminUser } from '../user/admin-user.entity'; import { AdminUser } from '../user/admin-user.entity';
import { AdminMenu, AdminMenuMeta } from './admin-menu.entity'; import { AdminMenu, AdminMenuMeta } from './admin-menu.entity';
@ -15,17 +16,20 @@ export class AdminMenuService {
constructor( constructor(
@InjectRepository(AdminMenu) @InjectRepository(AdminMenu)
private readonly menuRepository: Repository<AdminMenu>, private readonly menuRepository: Repository<AdminMenu>,
private readonly wordpressService: WordpressService,
) {} ) {}
async getAccessCodes(user: AdminUser) { async getAccessCodes(user: AdminUser) {
const menus = await this.getAllowedMenus(user); const menus = this.filterUnavailableFeatureMenus(
return menus await this.getAllowedMenus(user),
.map((menu) => menu.authCode) );
.filter((authCode) => !!authCode); return menus.map((menu) => menu.authCode).filter((authCode) => !!authCode);
} }
async getRouteMenus(user: AdminUser) { 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')); return this.buildMenuTree(menus.filter((menu) => menu.type !== 'button'));
} }
@ -114,6 +118,22 @@ export class AdminMenuService {
return [...menuMap.values()]; 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( private normalizeMenuInput(
data: MenuInput, data: MenuInput,
includeEmptyMeta: boolean, includeEmptyMeta: boolean,

View File

@ -13,7 +13,7 @@ import {
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { ToolsService } from '@/common'; import { vbenSuccess } from '@/common';
import { import {
WordpressArticleBodyDto, WordpressArticleBodyDto,
WordpressArticleListQueryDto, WordpressArticleListQueryDto,
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
@Controller('wordpress/article') @Controller('wordpress/article')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class WordpressArticleController { export class WordpressArticleController {
constructor( constructor(private readonly wordpressService: WordpressService) {}
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list') @Get('list')
@ApiOperation({ summary: '获取 WordPress 文章分页列表' }) @ApiOperation({ summary: '获取 WordPress 文章分页列表' })
@ -50,7 +47,7 @@ export class WordpressArticleController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.articleList(query, auth); const list = await this.wordpressService.articleList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list)); return res.send(vbenSuccess(list));
} }
@Get('detail') @Get('detail')
@ -60,7 +57,7 @@ export class WordpressArticleController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.articleDetail(id, auth); const detail = await this.wordpressService.articleDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail)); return res.send(vbenSuccess(detail));
} }
@Post('save') @Post('save')
@ -74,7 +71,7 @@ export class WordpressArticleController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.articleSave(body, auth); const result = await this.wordpressService.articleSave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('update') @Post('update')
@ -88,7 +85,7 @@ export class WordpressArticleController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.articleUpdate(body, auth); const result = await this.wordpressService.articleUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('remove') @Post('remove')
@ -109,6 +106,6 @@ export class WordpressArticleController {
auth, auth,
); );
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
} }

View File

@ -1,7 +1,6 @@
import { import {
Controller, Controller,
Get, Get,
HttpStatus,
Post, Post,
Req, Req,
Res, Res,
@ -10,7 +9,7 @@ import {
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { Public, ToolsService } from '@/common'; import { Public, vbenSuccess } from '@/common';
import { WordpressService } from './wordpress.service'; import { WordpressService } from './wordpress.service';
@ApiTags('wordpress-auth') @ApiTags('wordpress-auth')
@ -27,10 +26,7 @@ import { WordpressService } from './wordpress.service';
@Controller('wordpress/auth') @Controller('wordpress/auth')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class WordpressAuthController { export class WordpressAuthController {
constructor( constructor(private readonly wordpressService: WordpressService) {}
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Post('login') @Post('login')
@ApiOperation({ summary: '使用环境变量中的 WordPress 管理员账号自动认证' }) @ApiOperation({ summary: '使用环境变量中的 WordPress 管理员账号自动认证' })
@ -39,7 +35,7 @@ export class WordpressAuthController {
await this.wordpressService.loginWithConfiguredAdmin(); await this.wordpressService.loginWithConfiguredAdmin();
this.wordpressService.setAuthCookie(res, cookie); this.wordpressService.setAuthCookie(res, cookie);
return this.toolsService.res(HttpStatus.OK, '操作成功', { return vbenSuccess({
auth, auth,
user, user,
}); });
@ -51,7 +47,7 @@ export class WordpressAuthController {
logout(@Res({ passthrough: true }) res: Response) { logout(@Res({ passthrough: true }) res: Response) {
this.wordpressService.clearAuthCookie(res); this.wordpressService.clearAuthCookie(res);
return this.toolsService.res(HttpStatus.OK, '操作成功', true); return vbenSuccess(true);
} }
@Get('check') @Get('check')
@ -60,6 +56,6 @@ export class WordpressAuthController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const user = await this.wordpressService.checkAuth(auth); const user = await this.wordpressService.checkAuth(auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', user)); return res.send(vbenSuccess(user));
} }
} }

View File

@ -13,7 +13,7 @@ import {
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { ToolsService } from '@/common'; import { vbenSuccess } from '@/common';
import { import {
WordpressTermBodyDto, WordpressTermBodyDto,
WordpressTermListQueryDto, WordpressTermListQueryDto,
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
@Controller('wordpress/category') @Controller('wordpress/category')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class WordpressCategoryController { export class WordpressCategoryController {
constructor( constructor(private readonly wordpressService: WordpressService) {}
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list') @Get('list')
@ApiOperation({ summary: '获取 WordPress 分类分页列表' }) @ApiOperation({ summary: '获取 WordPress 分类分页列表' })
@ -50,7 +47,7 @@ export class WordpressCategoryController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.categoryList(query, auth); const list = await this.wordpressService.categoryList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list)); return res.send(vbenSuccess(list));
} }
@Get('detail') @Get('detail')
@ -60,7 +57,7 @@ export class WordpressCategoryController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.categoryDetail(id, auth); const detail = await this.wordpressService.categoryDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail)); return res.send(vbenSuccess(detail));
} }
@Post('save') @Post('save')
@ -74,7 +71,7 @@ export class WordpressCategoryController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.categorySave(body, auth); const result = await this.wordpressService.categorySave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('update') @Post('update')
@ -88,7 +85,7 @@ export class WordpressCategoryController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.categoryUpdate(body, auth); const result = await this.wordpressService.categoryUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('remove') @Post('remove')
@ -109,6 +106,6 @@ export class WordpressCategoryController {
auth, auth,
); );
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
} }

View File

@ -13,7 +13,7 @@ import {
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { ToolsService } from '@/common'; import { vbenSuccess } from '@/common';
import { import {
WordpressTermBodyDto, WordpressTermBodyDto,
WordpressTermListQueryDto, WordpressTermListQueryDto,
@ -35,10 +35,7 @@ import { WordpressService } from './wordpress.service';
@Controller('wordpress/tag') @Controller('wordpress/tag')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class WordpressTagController { export class WordpressTagController {
constructor( constructor(private readonly wordpressService: WordpressService) {}
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list') @Get('list')
@ApiOperation({ summary: '获取 WordPress 标签分页列表' }) @ApiOperation({ summary: '获取 WordPress 标签分页列表' })
@ -50,7 +47,7 @@ export class WordpressTagController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.tagList(query, auth); const list = await this.wordpressService.tagList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list)); return res.send(vbenSuccess(list));
} }
@Get('detail') @Get('detail')
@ -60,7 +57,7 @@ export class WordpressTagController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.tagDetail(id, auth); const detail = await this.wordpressService.tagDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail)); return res.send(vbenSuccess(detail));
} }
@Post('save') @Post('save')
@ -74,7 +71,7 @@ export class WordpressTagController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.tagSave(body, auth); const result = await this.wordpressService.tagSave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('update') @Post('update')
@ -88,7 +85,7 @@ export class WordpressTagController {
const auth = this.wordpressService.getAuthContext(req); const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.tagUpdate(body, auth); const result = await this.wordpressService.tagUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
@Post('remove') @Post('remove')
@ -109,6 +106,6 @@ export class WordpressTagController {
auth, auth,
); );
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result)); return res.send(vbenSuccess(result));
} }
} }

View File

@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { ToolsService } from '@/common';
import { WordpressArticleController } from './wordpress-article.controller'; import { WordpressArticleController } from './wordpress-article.controller';
import { WordpressAuthController } from './wordpress-auth.controller'; import { WordpressAuthController } from './wordpress-auth.controller';
import { WordpressCategoryController } from './wordpress-category.controller'; import { WordpressCategoryController } from './wordpress-category.controller';
@ -15,7 +14,7 @@ import { WordpressTagController } from './wordpress-tag.controller';
WordpressTagController, WordpressTagController,
WordpressCategoryController, WordpressCategoryController,
], ],
providers: [ToolsService, WordpressService], providers: [WordpressService],
exports: [WordpressService], exports: [WordpressService],
}) })
export class WordpressModule {} export class WordpressModule {}

View File

@ -23,6 +23,30 @@ export type WordpressLoginResult = {
user: any; 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 = { type WordpressRequestOptions = {
auth: WordpressAuthContext; auth: WordpressAuthContext;
body?: Record<string, unknown>; body?: Record<string, unknown>;
@ -46,6 +70,8 @@ const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth';
@Injectable() @Injectable()
export class WordpressService { export class WordpressService {
private availabilityCache: null | WordpressAvailabilityCache = null;
constructor(private readonly configService: ConfigService) {} constructor(private readonly configService: ConfigService) {}
getAuthContext(request: Request): WordpressAuthContext { getAuthContext(request: Request): WordpressAuthContext {
@ -79,9 +105,45 @@ export class WordpressService {
return response.data; return response.data;
} }
async loginWithConfiguredAdmin(): Promise< async tryLoginWithConfiguredAdmin(): Promise<WordpressOptionalLoginResult> {
WordpressLoginResult & { cookie: string } 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 username = this.configService.get<string>('WORDPRESS_ADMIN_USERNAME');
const password = this.configService.get<string>('WORDPRESS_ADMIN_PASSWORD'); const password = this.configService.get<string>('WORDPRESS_ADMIN_PASSWORD');
@ -93,8 +155,12 @@ export class WordpressService {
); );
} }
const cookie = await this.loginByPassword(username, password); const cookie = await this.loginByPassword(
const nonce = await this.fetchRestNonce(cookie); username,
password,
options.timeoutMs,
);
const nonce = await this.fetchRestNonce(cookie, options.timeoutMs);
if (!nonce) { if (!nonce) {
throwVbenError( 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', { const response = await this.request<any[]>('/wp-json/wp/v2/posts', {
auth, auth,
query: { query: {
@ -179,7 +248,10 @@ export class WordpressService {
return response.data; 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}`, { const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
auth, auth,
body: this.getArticleBody(body), body: this.getArticleBody(body),
@ -189,7 +261,11 @@ export class WordpressService {
return response.data; 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}`, { const response = await this.request(`/wp-json/wp/v2/posts/${id}`, {
auth, auth,
method: 'DELETE', method: 'DELETE',
@ -213,15 +289,25 @@ export class WordpressService {
return this.termSave('/wp-json/wp/v2/tags', body, auth); 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); 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); 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); 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); 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); 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); return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
} }
@ -346,7 +439,11 @@ export class WordpressService {
const data = await this.parseResponse(response); const data = await this.parseResponse(response);
// 兼容未开启 Apache rewrite 的 WordPress/wp-json 404 时自动回退到 ?rest_route=。 // 兼容未开启 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; continue;
} }
@ -380,14 +477,22 @@ export class WordpressService {
} }
} }
private async loginByPassword(username: string, password: string) { private async loginByPassword(
const response = await this.formRequest('/wp-login.php', { username: string,
password: string,
timeoutMs?: number,
) {
const response = await this.formRequest(
'/wp-login.php',
{
log: username, log: username,
pwd: password, pwd: password,
redirect_to: this.getUrl('/wp-admin/'), redirect_to: this.getUrl('/wp-admin/'),
testcookie: '1', testcookie: '1',
'wp-submit': 'Log In', 'wp-submit': 'Log In',
}); },
timeoutMs,
);
const setCookies = this.getSetCookieHeaders(response.headers); const setCookies = this.getSetCookieHeaders(response.headers);
const cookie = this.toCookieHeader(setCookies); const cookie = this.toCookieHeader(setCookies);
@ -403,15 +508,23 @@ export class WordpressService {
return cookie; return cookie;
} }
private async fetchRestNonce(cookie: string) { private async fetchRestNonce(cookie: string, timeoutMs?: number) {
const adminPaths = ['/wp-admin/', '/wp-admin/post-new.php', '/wp-admin/edit.php']; const adminPaths = [
'/wp-admin/',
'/wp-admin/post-new.php',
'/wp-admin/edit.php',
];
for (const path of adminPaths) { for (const path of adminPaths) {
const response = await this.rawRequest(path, { const response = await this.rawRequest(
path,
{
headers: { headers: {
Cookie: cookie, Cookie: cookie,
}, },
}); },
timeoutMs,
);
const html = await response.text().catch(() => ''); const html = await response.text().catch(() => '');
const nonce = this.extractRestNonce(html); const nonce = this.extractRestNonce(html);
@ -421,10 +534,16 @@ export class WordpressService {
return ''; 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); const form = new URLSearchParams(body);
return this.rawRequest(path, { return this.rawRequest(
path,
{
body: form, body: form,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -432,12 +551,21 @@ export class WordpressService {
}, },
method: 'POST', method: 'POST',
redirect: 'manual', redirect: 'manual',
}); },
timeoutMs,
);
} }
private async rawRequest(path: string, init: RequestInit = {}) { private async rawRequest(
path: string,
init: RequestInit = {},
timeoutMs?: number,
) {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.getTimeout()); const timer = setTimeout(
() => controller.abort(),
timeoutMs || this.getTimeout(),
);
try { try {
return await fetch(this.getUrl(path), { return await fetch(this.getUrl(path), {
@ -497,7 +625,8 @@ export class WordpressService {
} }
private getCookieOptions() { private getCookieOptions() {
const secure = this.configService.get<string>('ADMIN_COOKIE_SECURE') === 'true'; const secure =
this.configService.get<string>('ADMIN_COOKIE_SECURE') === 'true';
return { return {
httpOnly: true, httpOnly: true,
@ -551,6 +680,31 @@ export class WordpressService {
return Number(this.configService.get('WORDPRESS_TIMEOUT_MS') || 15000); 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) { private getPageQuery(query: WordpressPagedQueryDto) {
return { return {
order: query.order, 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) { private getErrorCause(err: unknown) {
const cause = (err as { cause?: { code?: string; message?: string } }) const cause = (err as { cause?: { code?: string; message?: string } })
?.cause; ?.cause;
@ -650,11 +828,16 @@ export class WordpressService {
} }
private getLoginErrorMessage(html: string) { 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 管理员登录失败'; 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) { private getSetCookieHeaders(headers: Headers) {
@ -778,4 +961,6 @@ export class WordpressService {
} }
} }
type WordpressPagedQueryDto = WordpressArticleListQueryDto | WordpressTermListQueryDto; type WordpressPagedQueryDto =
| WordpressArticleListQueryDto
| WordpressTermListQueryDto;

View File

@ -563,9 +563,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
expect(wordpressServiceMock.checkAuth).toHaveBeenCalledWith( expect(wordpressServiceMock.checkAuth).toHaveBeenCalledWith(
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressUser, data: wordpressUser,
}); });
}, },
@ -584,9 +583,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
expect.anything(), expect.anything(),
wordpressLoginResult.cookie, wordpressLoginResult.cookie,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: { data: {
auth: wordpressLoginResult.auth, auth: wordpressLoginResult.auth,
user: wordpressUser, user: wordpressUser,
@ -602,9 +600,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
expect(wordpressServiceMock.clearAuthCookie).toHaveBeenCalledWith( expect(wordpressServiceMock.clearAuthCookie).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: true, data: true,
}); });
}, },
@ -632,9 +629,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: { data: {
list: [wordpressArticle], list: [wordpressArticle],
total: 1, total: 1,
@ -654,9 +650,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
'1', '1',
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressArticle, data: wordpressArticle,
}); });
}, },
@ -680,9 +675,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressArticle, data: wordpressArticle,
}); });
}, },
@ -705,9 +699,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressArticle, data: wordpressArticle,
}); });
}, },
@ -728,9 +721,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
false, false,
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: true, data: true,
}); });
}, },
@ -758,9 +750,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: { data: {
list: [wordpressTerm], list: [wordpressTerm],
total: 1, total: 1,
@ -780,9 +771,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
'1', '1',
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -804,9 +794,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -829,9 +818,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -849,9 +837,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
true, true,
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: true, data: true,
}); });
}, },
@ -879,9 +866,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: { data: {
list: [wordpressTerm], list: [wordpressTerm],
total: 1, total: 1,
@ -901,9 +887,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
'1', '1',
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -927,9 +912,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -954,9 +938,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
}, },
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: wordpressTerm, data: wordpressTerm,
}); });
}, },
@ -974,9 +957,8 @@ const routeTestCases: Record<string, RouteTestCase> = {
true, true,
wordpressAuthContext, wordpressAuthContext,
); );
expect(response.body).toEqual({ expect(response.body).toMatchObject({
code: 200, code: 0,
msg: '操作成功',
data: true, data: true,
}); });
}, },