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_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000
WORDPRESS_LOGIN_TIMEOUT_MS=3000
WORDPRESS_AVAILABILITY_TTL_MS=60000
ADMIN_TOKEN_SECRET=change-me
ADMIN_COOKIE_SECURE=false

24
API.md
View File

@ -25,7 +25,7 @@
## 功能模块
| 模块 | 说明 |
| --------- | --------------------------------------------------------------------- |
| --------- | ----------------------------------------------------------------------------------------- |
| Component | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
@ -62,16 +62,18 @@ 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_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 |
@ -81,6 +83,8 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
如果 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/*` 接口路径保持兼容,但仍需要登录态。
@ -88,7 +92,7 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
`admin_dict` 表核心字段:
| 字段 | 类型 | 说明 |
| ----------- | ------- | ------------------------------------------------------ |
| ------------ | ------- | ------------------------------------------------------ |
| id | string | 字典数字 ID |
| dictCode | string | 字典分组,例如 `COMPONENT_TYPE`、`CHART`、`COMPONENT` |
| label | string | 展示文本 |
@ -101,7 +105,7 @@ WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordP
当前数据库示例关系:
| dictCode | value | label | childrenCode |
| -------------- | ----- | ----- | ----------- |
| -------------- | ----- | ----- | ------------ |
| COMPONENT_TYPE | 1 | 图表 | CHART |
| COMPONENT_TYPE | 2 | 组件 | COMPONENT |
@ -324,7 +328,7 @@ 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 |
@ -396,7 +400,7 @@ Query
### WordPress Article
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| ---- | ------------------------------------------- | ---------------- |
| GET | `/wordpress/article/list` | 获取文章分页列表 |
| GET | `/wordpress/article/detail?id=1` | 获取文章详情 |
| POST | `/wordpress/article/save` | 新增文章 |
@ -406,7 +410,7 @@ Query
列表 Query
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| ---------- | ------ | ---- | ----------------------- |
| pageNo | number | 否 | 页码,默认 `1` |
| pageSize | number | 否 | 每页条数,默认 `10` |
| search | string | 否 | 关键词搜索 |
@ -434,7 +438,7 @@ Query
### WordPress Tag
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| ---- | --------------------------------------- | ---------------- |
| GET | `/wordpress/tag/list` | 获取标签分页列表 |
| GET | `/wordpress/tag/detail?id=1` | 获取标签详情 |
| POST | `/wordpress/tag/save` | 新增标签 |
@ -455,7 +459,7 @@ Query
### WordPress Category
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| ---- | -------------------------------------------- | ---------------- |
| GET | `/wordpress/category/list` | 获取分类分页列表 |
| GET | `/wordpress/category/detail?id=1` | 获取分类详情 |
| POST | `/wordpress/category/save` | 新增分类 |

View File

@ -14,7 +14,7 @@
## 功能模块
| 模块 | 说明 |
| --- | --- |
| ----------- | ----------------------------------------------------------------------------------------- |
| `component` | Admin 下受保护的组件/图表模板列表、详情、新增、编辑、逻辑删除,数据表为 `admin_component` |
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
@ -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 重新持久化登录态。

View File

@ -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),

View File

@ -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,
});
}

View File

@ -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,

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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 {}

View File

@ -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', {
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, {
const response = await this.rawRequest(
path,
{
headers: {
Cookie: cookie,
},
});
},
timeoutMs,
);
const html = await response.text().catch(() => '');
const nonce = this.extractRestNonce(html);
@ -421,10 +534,16 @@ 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, {
return this.rawRequest(
path,
{
body: form,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@ -432,12 +551,21 @@ export class WordpressService {
},
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;

View File

@ -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,
});
},