feat(api): 接入WordPress内容管理认证

This commit is contained in:
sunlei 2026-05-17 16:45:08 +08:00
parent a920a92ad0
commit 276b82b8d8
12 changed files with 1993 additions and 2 deletions

View File

@ -11,6 +11,11 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online
WORDPRESS_BASE_URL=http://localhost
WORDPRESS_ADMIN_USERNAME=admin
WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000
ADMIN_TOKEN_SECRET=change-me
ADMIN_COOKIE_SECURE=false
SNOWFLAKE_WORKER_ID=1

141
API.md
View File

@ -30,6 +30,7 @@
| Dict | 基于新 `admin_dict` 表的数据库字典查询,以及组件一级类型到二级类型的数据库关系映射 |
| Admin | Vben Admin 真实接口,包含认证、用户、菜单、角色、部门、时区和上传适配 |
| MinIO | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
| WordPress | WordPress 文章、标签、分类管理,复用客户端 WordPress 登录态访问 REST API |
| Common | 统一响应 Swagger 注解、字典翻译注解、`POST */save` 请求体规范化拦截器 |
## 通用规则
@ -54,6 +55,32 @@ Admin、Component、Dict 与 MinIO 业务接口统一走 `JwtAuthGuard`。请求
`@Public()` 可用于保留不需要认证的接口口子,目前登录、刷新 token、退出登录和部分示例状态测试接口放行。
### WordPress 认证透传
WordPress 侧只使用客户端登录态,后端不走 BasicAuth。当前 WordPress 只有单管理员账号且不开放注册,管理员账号配置放在 env 中,`/wordpress/auth/login` 会在 Admin 登录成功后使用该账号自动登录 WordPress把 WordPress cookie 保存到本系统 httpOnly cookie再把 REST nonce 和用户信息返回给前端持久化。
环境变量:
| 变量 | 说明 |
| --- | --- |
| `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 登录态来源:
| 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_component` 表中,字典数据维护在新的 `admin_dict` 表中。`Component.typeMsg`、`Component.componentTypeMsg` 会在 TypeORM `AfterLoad` 阶段根据字典缓存自动映射;旧 `/dict/*` 接口路径保持兼容,但仍需要登录态。
@ -333,6 +360,120 @@ Query
- `sql/migrate-component-to-admin-component.sql`:将旧 `component` 表数据迁移到 `admin_component`,并把旧表改名为备份表。
- `sql/fix-admin-menu-meta.sql`:修复基础后台菜单 `meta` 被旧数据或错误保存覆盖为空的问题。
## WordPress 接口
所有 `/wordpress/*` 管理接口都需要本系统后台登录态和 WordPress 客户端登录态。Admin 登录通过后会自动调用 `/wordpress/auth/login` 建立 WordPress 授权态;后端只把 WordPress cookie 保存到本系统 httpOnly cookie不把 cookie 明文放入前端持久化。
### POST `/wordpress/auth/login`
使用 env 中的 `WORDPRESS_ADMIN_USERNAME``WORDPRESS_ADMIN_PASSWORD` 登录 WordPress写入 `kt_wordpress_auth` httpOnly cookie并返回前端需要持久化的 REST nonce 和 WordPress 当前用户信息。
响应 `data`
```json
{
"auth": {
"nonce": "wordpress-rest-nonce",
"type": "cookie"
},
"user": {
"id": 1,
"name": "admin"
}
}
```
### POST `/wordpress/auth/logout`
清理本系统保存的 WordPress 授权 cookie。该接口用于 Admin 退出登录时同步清理 WordPress 授权态。
### GET `/wordpress/auth/check`
调用 WordPress `/wp-json/wp/v2/users/me?context=edit` 校验当前客户端 WordPress 登录态。
响应 `data`WordPress 当前用户信息。
### 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` | 删除文章 |
列表 Query
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| pageNo | number | 否 | 页码,默认 `1` |
| pageSize | number | 否 | 每页条数,默认 `10` |
| search | string | 否 | 关键词搜索 |
| status | string | 否 | 文章状态,默认 `any` |
| categories | string | 否 | 分类 ID多个用逗号分隔 |
| tags | string | 否 | 标签 ID多个用逗号分隔 |
新增/编辑 Body 常用字段:
```json
{
"id": 1,
"title": "文章标题",
"content": "文章内容",
"excerpt": "文章摘要",
"status": "draft",
"slug": "post-slug",
"categories": [1],
"tags": [2],
"featured_media": 10,
"sticky": false
}
```
### 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` | 删除标签 |
新增/编辑 Body
```json
{
"id": 1,
"name": "标签名称",
"slug": "tag-slug",
"description": "标签描述"
}
```
### 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` | 删除分类 |
新增/编辑 Body
```json
{
"id": 1,
"name": "分类名称",
"slug": "category-slug",
"description": "分类描述",
"parent": 0
}
```
## MinIO 接口
### GET `/minio/check`

View File

@ -1,6 +1,6 @@
# KT Template Online API
`kt-template-online-api` 是 KT Template Online 的后端服务,负责组件模板、数据库字典和 MinIO 文件能力。前台列表和 Playground 保存都通过本服务完成数据读写。
`kt-template-online-api` 是 KT Template Online 的后端服务,负责组件模板、数据库字典、MinIO 文件和 WordPress 内容管理能力。前台列表和 Playground 保存都通过本服务完成数据读写。
## 技术栈
@ -19,6 +19,7 @@
| `dict` | 基于新 `admin_dict` 表的字典查询,维护组件一级类型和二级类型关系 |
| `admin` | Vben Admin 真实接口,包含登录、用户、菜单、角色、部门、时区、上传和示例表格 |
| `minio` | Bucket 检查/创建、文件上传、列表、临时访问地址、下载和删除 |
| `wordpress` | WordPress 文章、标签、分类管理接口,复用客户端 WordPress 登录态访问 REST API |
| `common` | 响应注解、字典翻译、`POST */save` 请求体规范化等通用能力 |
## 目录结构
@ -28,6 +29,7 @@ src
common/ # 通用装饰器、拦截器、服务、Swagger 封装
admin/ # Vben Admin 后台认证、组件、字典、菜单、角色、部门等接口
minio/ # MinIO 文件模块
wordpress/ # WordPress REST API 文章、标签、分类代理模块
types/ # 全局类型声明
app.module.ts # 全局模块、数据库、MinIO、拦截器注册
main.ts # Swagger、Knife4j、端口启动入口
@ -51,6 +53,11 @@ MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online
WORDPRESS_BASE_URL=http://localhost
WORDPRESS_ADMIN_USERNAME=admin
WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000
ADMIN_TOKEN_SECRET=change-me
ADMIN_COOKIE_SECURE=false
SNOWFLAKE_WORKER_ID=1
@ -105,6 +112,10 @@ pnpm test:e2e # e2e 测试
- 旧 `component` 表迁移到 `admin_component` 时,执行 `sql/migrate-component-to-admin-component.sql`,脚本会把旧表重命名为备份表。
- 如果旧版本曾写入 `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 登录成功后会调用 `/wordpress/auth/login` 自动登录 WordPress后端把 WordPress cookie 写入本系统 httpOnly cookie前端只持久化 REST nonce 和用户信息。
- 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 重新持久化登录态。
- `kt-template-admin` 开发环境通过 `/api` 代理到本服务 `48085`,已关闭 Vben Nitro Mock。
- `POST /component/save` 新增组件,`POST /component/update` 编辑组件。

View File

@ -8,6 +8,7 @@ import { MinioModule } from 'nestjs-minio-client';
import { MinioClientModule } from './minio/minio.module';
import { SaveBodyInterceptor } from './common';
import { AdminModule } from './admin/admin.module';
import { WordpressModule } from './wordpress/wordpress.module';
@Module({
imports: [
@ -48,6 +49,7 @@ import { AdminModule } from './admin/admin.module';
}),
MinioClientModule,
AdminModule,
WordpressModule,
],
providers: [
AppService,

View File

@ -0,0 +1,114 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
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 {
WordpressArticleBodyDto,
WordpressArticleListQueryDto,
WordpressArticleUpdateBodyDto,
} from './wordpress.dto';
import { WordpressService } from './wordpress.service';
@ApiTags('wordpress-article')
@ApiHeader({
name: 'X-WordPress-Authorization',
required: false,
description: 'WordPress 客户端登录后拿到的授权头,例如 Bearer token',
})
@ApiHeader({
name: 'X-WP-Nonce',
required: false,
description: 'WordPress REST cookie 认证 nonce',
})
@Controller('wordpress/article')
@UseGuards(JwtAuthGuard)
export class WordpressArticleController {
constructor(
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list')
@ApiOperation({ summary: '获取 WordPress 文章分页列表' })
async list(
@Req() req: Request,
@Res() res,
@Query() query: WordpressArticleListQueryDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.articleList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
}
@Get('detail')
@ApiOperation({ summary: '获取 WordPress 文章详情' })
@ApiQuery({ name: 'id', type: Number })
async detail(@Req() req: Request, @Res() res, @Query('id') id: string) {
const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.articleDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
}
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增 WordPress 文章' })
async save(
@Req() req: Request,
@Res() res,
@Body() body: WordpressArticleBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.articleSave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑 WordPress 文章' })
async update(
@Req() req: Request,
@Res() res,
@Body() body: WordpressArticleUpdateBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.articleUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除 WordPress 文章' })
@ApiQuery({ name: 'id', type: Number })
@ApiQuery({ name: 'force', required: false, type: Boolean })
async remove(
@Req() req: Request,
@Res() res,
@Query('id') id: string,
@Query('force') force?: string,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.articleRemove(
id,
force !== 'false',
auth,
);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
}

View File

@ -0,0 +1,65 @@
import {
Controller,
Get,
HttpStatus,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
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 { WordpressService } from './wordpress.service';
@ApiTags('wordpress-auth')
@ApiHeader({
name: 'X-WordPress-Authorization',
required: false,
description: 'WordPress 客户端登录后拿到的授权头,例如 Bearer token',
})
@ApiHeader({
name: 'X-WP-Nonce',
required: false,
description: 'WordPress REST cookie 认证 nonce',
})
@Controller('wordpress/auth')
@UseGuards(JwtAuthGuard)
export class WordpressAuthController {
constructor(
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Post('login')
@ApiOperation({ summary: '使用环境变量中的 WordPress 管理员账号自动认证' })
async login(@Res({ passthrough: true }) res: Response) {
const { auth, cookie, user } =
await this.wordpressService.loginWithConfiguredAdmin();
this.wordpressService.setAuthCookie(res, cookie);
return this.toolsService.res(HttpStatus.OK, '操作成功', {
auth,
user,
});
}
@Post('logout')
@Public()
@ApiOperation({ summary: '清理本系统保存的 WordPress 授权态' })
logout(@Res({ passthrough: true }) res: Response) {
this.wordpressService.clearAuthCookie(res);
return this.toolsService.res(HttpStatus.OK, '操作成功', true);
}
@Get('check')
@ApiOperation({ summary: '校验 WordPress 客户端登录态' })
async check(@Req() req: Request, @Res() res) {
const auth = this.wordpressService.getAuthContext(req);
const user = await this.wordpressService.checkAuth(auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', user));
}
}

View File

@ -0,0 +1,114 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
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 {
WordpressTermBodyDto,
WordpressTermListQueryDto,
WordpressTermUpdateBodyDto,
} from './wordpress.dto';
import { WordpressService } from './wordpress.service';
@ApiTags('wordpress-category')
@ApiHeader({
name: 'X-WordPress-Authorization',
required: false,
description: 'WordPress 客户端登录后拿到的授权头,例如 Bearer token',
})
@ApiHeader({
name: 'X-WP-Nonce',
required: false,
description: 'WordPress REST cookie 认证 nonce',
})
@Controller('wordpress/category')
@UseGuards(JwtAuthGuard)
export class WordpressCategoryController {
constructor(
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list')
@ApiOperation({ summary: '获取 WordPress 分类分页列表' })
async list(
@Req() req: Request,
@Res() res,
@Query() query: WordpressTermListQueryDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.categoryList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
}
@Get('detail')
@ApiOperation({ summary: '获取 WordPress 分类详情' })
@ApiQuery({ name: 'id', type: Number })
async detail(@Req() req: Request, @Res() res, @Query('id') id: string) {
const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.categoryDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
}
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增 WordPress 分类' })
async save(
@Req() req: Request,
@Res() res,
@Body() body: WordpressTermBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.categorySave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑 WordPress 分类' })
async update(
@Req() req: Request,
@Res() res,
@Body() body: WordpressTermUpdateBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.categoryUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除 WordPress 分类' })
@ApiQuery({ name: 'id', type: Number })
@ApiQuery({ name: 'force', required: false, type: Boolean })
async remove(
@Req() req: Request,
@Res() res,
@Query('id') id: string,
@Query('force') force?: string,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.categoryRemove(
id,
force !== 'false',
auth,
);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
}

View File

@ -0,0 +1,114 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
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 {
WordpressTermBodyDto,
WordpressTermListQueryDto,
WordpressTermUpdateBodyDto,
} from './wordpress.dto';
import { WordpressService } from './wordpress.service';
@ApiTags('wordpress-tag')
@ApiHeader({
name: 'X-WordPress-Authorization',
required: false,
description: 'WordPress 客户端登录后拿到的授权头,例如 Bearer token',
})
@ApiHeader({
name: 'X-WP-Nonce',
required: false,
description: 'WordPress REST cookie 认证 nonce',
})
@Controller('wordpress/tag')
@UseGuards(JwtAuthGuard)
export class WordpressTagController {
constructor(
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
) {}
@Get('list')
@ApiOperation({ summary: '获取 WordPress 标签分页列表' })
async list(
@Req() req: Request,
@Res() res,
@Query() query: WordpressTermListQueryDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const list = await this.wordpressService.tagList(query, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', list));
}
@Get('detail')
@ApiOperation({ summary: '获取 WordPress 标签详情' })
@ApiQuery({ name: 'id', type: Number })
async detail(@Req() req: Request, @Res() res, @Query('id') id: string) {
const auth = this.wordpressService.getAuthContext(req);
const detail = await this.wordpressService.tagDetail(id, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', detail));
}
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增 WordPress 标签' })
async save(
@Req() req: Request,
@Res() res,
@Body() body: WordpressTermBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.tagSave(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑 WordPress 标签' })
async update(
@Req() req: Request,
@Res() res,
@Body() body: WordpressTermUpdateBodyDto,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.tagUpdate(body, auth);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
@Post('remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除 WordPress 标签' })
@ApiQuery({ name: 'id', type: Number })
@ApiQuery({ name: 'force', required: false, type: Boolean })
async remove(
@Req() req: Request,
@Res() res,
@Query('id') id: string,
@Query('force') force?: string,
) {
const auth = this.wordpressService.getAuthContext(req);
const result = await this.wordpressService.tagRemove(
id,
force !== 'false',
auth,
);
return res.send(this.toolsService.res(HttpStatus.OK, '操作成功', result));
}
}

View File

@ -0,0 +1,150 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WordpressPagedQueryDto {
@ApiPropertyOptional({
type: Number,
default: 1,
description: '页码',
})
pageNo?: number;
@ApiPropertyOptional({
type: Number,
default: 10,
description: '每页条数',
})
pageSize?: number;
@ApiPropertyOptional({
description: '关键词搜索',
})
search?: string;
@ApiPropertyOptional({
description: '排序字段',
})
orderby?: string;
@ApiPropertyOptional({
enum: ['asc', 'desc'],
description: '排序方向',
})
order?: 'asc' | 'desc';
}
export class WordpressArticleListQueryDto extends WordpressPagedQueryDto {
@ApiPropertyOptional({
description: '文章状态,默认 any',
})
status?: string;
@ApiPropertyOptional({
description: '分类 ID多个使用逗号分隔',
})
categories?: string;
@ApiPropertyOptional({
description: '标签 ID多个使用逗号分隔',
})
tags?: string;
@ApiPropertyOptional({
description: '作者 ID',
})
author?: string;
}
export class WordpressTermListQueryDto extends WordpressPagedQueryDto {
@ApiPropertyOptional({
description: '是否隐藏空分类/标签',
})
hide_empty?: boolean;
@ApiPropertyOptional({
description: '分类父级 ID仅分类模块使用',
})
parent?: string;
}
export class WordpressArticleBodyDto {
@ApiProperty({
description: '文章标题',
})
title: string;
@ApiPropertyOptional({
description: '文章内容',
})
content?: string;
@ApiPropertyOptional({
description: '文章摘要',
})
excerpt?: string;
@ApiPropertyOptional({
description: '文章状态,例如 publish、draft、pending、private',
})
status?: string;
@ApiPropertyOptional({
description: '文章别名',
})
slug?: string;
@ApiPropertyOptional({
description: '分类 ID 数组或逗号分隔字符串',
})
categories?: number[] | string;
@ApiPropertyOptional({
description: '标签 ID 数组或逗号分隔字符串',
})
tags?: number[] | string;
@ApiPropertyOptional({
description: '特色媒体 ID',
})
featured_media?: number;
@ApiPropertyOptional({
description: '是否置顶',
})
sticky?: boolean;
}
export class WordpressArticleUpdateBodyDto extends WordpressArticleBodyDto {
@ApiProperty({
description: 'WordPress 文章 ID',
})
id: number;
}
export class WordpressTermBodyDto {
@ApiProperty({
description: '名称',
})
name: string;
@ApiPropertyOptional({
description: '别名',
})
slug?: string;
@ApiPropertyOptional({
description: '描述',
})
description?: string;
@ApiPropertyOptional({
description: '父级分类 ID仅分类模块使用',
})
parent?: number;
}
export class WordpressTermUpdateBodyDto extends WordpressTermBodyDto {
@ApiProperty({
description: 'WordPress 分类/标签 ID',
})
id: number;
}

View File

@ -0,0 +1,20 @@
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';
import { WordpressService } from './wordpress.service';
import { WordpressTagController } from './wordpress-tag.controller';
@Module({
imports: [AdminAuthGuardModule],
controllers: [
WordpressAuthController,
WordpressArticleController,
WordpressTagController,
WordpressCategoryController,
],
providers: [ToolsService, WordpressService],
})
export class WordpressModule {}

View File

@ -0,0 +1,748 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Request, Response as ExpressResponse } from 'express';
import { throwVbenError } from '@/common';
import type {
WordpressArticleBodyDto,
WordpressArticleListQueryDto,
WordpressTermBodyDto,
WordpressTermListQueryDto,
} from './wordpress.dto';
export type WordpressAuthContext = {
authorization?: string;
cookie?: string;
nonce?: string;
};
export type WordpressLoginResult = {
auth: {
nonce: string;
type: 'cookie';
};
user: any;
};
type WordpressRequestOptions = {
auth: WordpressAuthContext;
body?: Record<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>;
};
type WordpressResponse<T> = {
data: T;
total?: number;
};
const WORDPRESS_COOKIE_PREFIXES = [
'wordpress_',
'wordpress_logged_in_',
'wp-settings-',
'wp-postpass_',
'comment_author_',
];
const WORDPRESS_AUTH_COOKIE = 'kt_wordpress_auth';
@Injectable()
export class WordpressService {
constructor(private readonly configService: ConfigService) {}
getAuthContext(request: Request): WordpressAuthContext {
const authorization =
this.readHeader(request, 'x-wordpress-authorization') ||
this.readHeader(request, 'x-wp-authorization') ||
this.getForwardableAuthorization(request);
const nonce =
this.readHeader(request, 'x-wp-nonce') ||
this.readHeader(request, 'x-wordpress-nonce');
const cookie =
this.readHeader(request, 'x-wordpress-cookie') ||
this.readCookie(request, WORDPRESS_AUTH_COOKIE) ||
this.getWordpressCookie(request.headers.cookie);
return {
authorization,
cookie,
nonce,
};
}
async checkAuth(auth: WordpressAuthContext) {
const response = await this.request('/wp-json/wp/v2/users/me', {
auth,
query: {
context: 'edit',
},
});
return response.data;
}
async loginWithConfiguredAdmin(): Promise<
WordpressLoginResult & { cookie: string }
> {
const username = this.configService.get<string>('WORDPRESS_ADMIN_USERNAME');
const password = this.configService.get<string>('WORDPRESS_ADMIN_PASSWORD');
if (!username || !password) {
throwVbenError(
'WordPress 管理员账号未配置',
HttpStatus.BAD_REQUEST,
'WordPressConfigError',
);
}
const cookie = await this.loginByPassword(username, password);
const nonce = await this.fetchRestNonce(cookie);
if (!nonce) {
throwVbenError(
'WordPress 登录成功但未获取 REST nonce',
HttpStatus.BAD_GATEWAY,
'WordPressNonceError',
);
}
const user = await this.checkAuth({
cookie,
nonce,
});
return {
auth: {
nonce,
type: 'cookie',
},
cookie,
user,
};
}
setAuthCookie(res: ExpressResponse, cookie: string) {
res.cookie(WORDPRESS_AUTH_COOKIE, cookie, {
...this.getCookieOptions(),
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
clearAuthCookie(res: ExpressResponse) {
res.clearCookie(WORDPRESS_AUTH_COOKIE, this.getCookieOptions());
res.clearCookie(WORDPRESS_AUTH_COOKIE, {
...this.getCookieOptions(),
path: '/api/wordpress',
});
res.clearCookie(WORDPRESS_AUTH_COOKIE, {
...this.getCookieOptions(),
path: '/wordpress',
});
}
async articleList(query: WordpressArticleListQueryDto, auth: WordpressAuthContext) {
const response = await this.request<any[]>('/wp-json/wp/v2/posts', {
auth,
query: {
...this.getPageQuery(query),
author: query.author,
categories: query.categories,
context: 'edit',
search: query.search,
status: query.status || 'any',
tags: query.tags,
},
});
return {
list: response.data,
total: response.total || 0,
};
}
async articleDetail(id: string | number, auth: WordpressAuthContext) {
const response = await this.request(`/wp-json/wp/v2/posts/${id}`, {
auth,
query: {
context: 'edit',
},
});
return response.data;
}
async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) {
const response = await this.request('/wp-json/wp/v2/posts', {
auth,
body: this.getArticleBody(body),
method: 'POST',
});
return response.data;
}
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),
method: 'POST',
});
return response.data;
}
async articleRemove(id: string | number, force: boolean, auth: WordpressAuthContext) {
const response = await this.request(`/wp-json/wp/v2/posts/${id}`, {
auth,
method: 'DELETE',
query: {
force,
},
});
return response.data;
}
async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
return this.termList('/wp-json/wp/v2/tags', query, auth);
}
async tagDetail(id: string | number, auth: WordpressAuthContext) {
return this.termDetail('/wp-json/wp/v2/tags', id, auth);
}
async tagSave(body: WordpressTermBodyDto, auth: WordpressAuthContext) {
return this.termSave('/wp-json/wp/v2/tags', body, auth);
}
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) {
return this.termRemove('/wp-json/wp/v2/tags', id, force, auth);
}
async categoryList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
return this.termList('/wp-json/wp/v2/categories', query, auth);
}
async categoryDetail(id: string | number, auth: WordpressAuthContext) {
return this.termDetail('/wp-json/wp/v2/categories', id, auth);
}
async categorySave(body: WordpressTermBodyDto, auth: WordpressAuthContext) {
return this.termSave('/wp-json/wp/v2/categories', body, auth);
}
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) {
return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
}
private async termList(
path: string,
query: WordpressTermListQueryDto,
auth: WordpressAuthContext,
) {
const response = await this.request<any[]>(path, {
auth,
query: {
...this.getPageQuery(query),
context: 'edit',
hide_empty: query.hide_empty,
parent: query.parent,
search: query.search,
},
});
return {
list: response.data,
total: response.total || 0,
};
}
private async termDetail(
path: string,
id: string | number,
auth: WordpressAuthContext,
) {
const response = await this.request(`${path}/${id}`, {
auth,
query: {
context: 'edit',
},
});
return response.data;
}
private async termSave(
path: string,
body: WordpressTermBodyDto,
auth: WordpressAuthContext,
) {
const response = await this.request(path, {
auth,
body: this.getTermBody(body),
method: 'POST',
});
return response.data;
}
private async termUpdate(
path: string,
body: WordpressTermBodyDto & { id: number },
auth: WordpressAuthContext,
) {
const response = await this.request(`${path}/${body.id}`, {
auth,
body: this.getTermBody(body),
method: 'POST',
});
return response.data;
}
private async termRemove(
path: string,
id: string | number,
force: boolean,
auth: WordpressAuthContext,
) {
const response = await this.request(`${path}/${id}`, {
auth,
method: 'DELETE',
query: {
force,
},
});
return response.data;
}
private async request<T>(
path: string,
options: WordpressRequestOptions,
): Promise<WordpressResponse<T>> {
this.assertAuthContext(options.auth);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.getTimeout());
try {
const urls = this.getRequestUrls(path, options.query);
for (let index = 0; index < urls.length; index += 1) {
const response = await fetch(urls[index], {
body: options.body ? JSON.stringify(options.body) : undefined,
headers: this.getHeaders(options.auth, !!options.body),
method: options.method || 'GET',
redirect: 'follow',
signal: controller.signal,
});
const data = await this.parseResponse(response);
// 兼容未开启 Apache rewrite 的 WordPress/wp-json 404 时自动回退到 ?rest_route=。
if (!response.ok && response.status === 404 && index < urls.length - 1) {
continue;
}
if (!response.ok) {
throwVbenError(
this.getErrorMessage(data, response.status),
response.status,
data,
);
}
return {
data: data as T,
total: Number(response.headers.get('x-wp-total') || 0),
};
}
throwVbenError('WordPress 请求失败', HttpStatus.BAD_GATEWAY);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throwVbenError('WordPress 请求超时', HttpStatus.GATEWAY_TIMEOUT);
}
throw err;
} finally {
clearTimeout(timer);
}
}
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',
});
const setCookies = this.getSetCookieHeaders(response.headers);
const cookie = this.toCookieHeader(setCookies);
if (!cookie || !/wordpress_(?:logged_in|sec)_/i.test(cookie)) {
const body = await response.text().catch(() => '');
throwVbenError(
this.getLoginErrorMessage(body),
HttpStatus.UNAUTHORIZED,
'WordPressLoginError',
);
}
return cookie;
}
private async fetchRestNonce(cookie: string) {
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 html = await response.text().catch(() => '');
const nonce = this.extractRestNonce(html);
if (nonce) return nonce;
}
return '';
}
private async formRequest(path: string, body: Record<string, string>) {
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',
},
method: 'POST',
redirect: 'manual',
});
}
private async rawRequest(path: string, init: RequestInit = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.getTimeout());
try {
return await fetch(this.getUrl(path), {
...init,
signal: controller.signal,
});
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throwVbenError('WordPress 请求超时', HttpStatus.GATEWAY_TIMEOUT);
}
throw err;
} finally {
clearTimeout(timer);
}
}
private assertAuthContext(auth: WordpressAuthContext) {
const hasToken = !!auth.authorization;
const hasCookieLogin = !!auth.cookie && !!auth.nonce;
if (hasToken || hasCookieLogin) return;
throwVbenError(
'缺少 WordPress 客户端登录态',
HttpStatus.UNAUTHORIZED,
'WordPressUnauthorized',
);
}
private getHeaders(auth: WordpressAuthContext, hasBody: boolean) {
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (hasBody) {
headers['Content-Type'] = 'application/json';
}
if (auth.authorization) {
headers.Authorization = auth.authorization;
}
if (auth.cookie) {
headers.Cookie = auth.cookie;
}
if (auth.nonce) {
headers['X-WP-Nonce'] = auth.nonce;
}
return headers;
}
private getCookieOptions() {
const secure = this.configService.get<string>('ADMIN_COOKIE_SECURE') === 'true';
return {
httpOnly: true,
path: '/',
sameSite: secure ? ('none' as const) : ('lax' as const),
secure,
};
}
private getUrl(path: string, query?: Record<string, unknown>) {
const baseUrl = this.configService.get<string>('WORDPRESS_BASE_URL');
if (!baseUrl) {
throwVbenError(
'WORDPRESS_BASE_URL 未配置',
HttpStatus.BAD_REQUEST,
'WordPressConfigError',
);
}
const url = new URL(`${baseUrl.replace(/\/+$/g, '')}${path}`);
Object.entries(query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, `${value}`);
});
return url.toString();
}
private getRequestUrls(path: string, query?: Record<string, unknown>) {
const urls = [this.getUrl(path, query)];
if (path.startsWith('/wp-json/')) {
urls.push(this.getRestRouteUrl(path, query));
}
return urls;
}
private getRestRouteUrl(path: string, query?: Record<string, unknown>) {
const restRoute = path.replace(/^\/wp-json/, '') || '/';
const url = new URL(this.getUrl('/', query));
url.searchParams.set('rest_route', restRoute);
return url.toString();
}
private getTimeout() {
return Number(this.configService.get('WORDPRESS_TIMEOUT_MS') || 15000);
}
private getPageQuery(query: WordpressPagedQueryDto) {
return {
order: query.order,
orderby: query.orderby,
page: Number(query.pageNo || 1),
per_page: Number(query.pageSize || 10),
};
}
private getArticleBody(body: WordpressArticleBodyDto) {
return this.pickDefined({
categories: this.normalizeIdList(body.categories),
content: body.content,
excerpt: body.excerpt,
featured_media: body.featured_media,
slug: body.slug,
status: body.status,
sticky: body.sticky,
tags: this.normalizeIdList(body.tags),
title: body.title,
});
}
private getTermBody(body: WordpressTermBodyDto) {
return this.pickDefined({
description: body.description,
name: body.name,
parent: body.parent,
slug: body.slug,
});
}
private normalizeIdList(value?: number[] | string) {
if (Array.isArray(value)) return value;
if (typeof value !== 'string') return value;
return value
.split(',')
.map((item) => Number(item.trim()))
.filter((item) => !Number.isNaN(item));
}
private pickDefined(payload: Record<string, unknown>) {
return Object.entries(payload).reduce<Record<string, unknown>>(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
},
{},
);
}
private async parseResponse(response: globalThis.Response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
private getErrorMessage(data: any, status: number) {
if (data?.message) return data.message;
if (typeof data === 'string' && data) return data;
return `WordPress 请求失败:${status}`;
}
private getLoginErrorMessage(html: string) {
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();
}
private getSetCookieHeaders(headers: Headers) {
const getSetCookie = (headers as any).getSetCookie;
if (typeof getSetCookie === 'function') {
return getSetCookie.call(headers) as string[];
}
const raw = (headers as any).raw?.()?.['set-cookie'];
if (Array.isArray(raw)) return raw as string[];
const setCookie = headers.get('set-cookie');
if (!setCookie) return [];
return this.splitSetCookieHeader(setCookie);
}
private splitSetCookieHeader(value: string) {
return value.split(/,(?=\s*[^;,]+=)/).map((item) => item.trim());
}
private toCookieHeader(setCookies: string[]) {
const cookies = setCookies
.map((item) => item.split(';')[0]?.trim())
.filter((item): item is string => {
if (!item) return false;
const [key] = item.split('=');
return WORDPRESS_COOKIE_PREFIXES.some((prefix) =>
key.startsWith(prefix),
);
});
return cookies.join('; ');
}
private extractRestNonce(html: string) {
const patterns = [
/"nonce"\s*:\s*"([^"]+)"/i,
/wpApiSettings\s*=\s*\{[\s\S]*?nonce["']?\s*:\s*["']([^"']+)/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match?.[1]) {
return match[1].replace(/\\\//g, '/');
}
}
return '';
}
private readHeader(request: Request, name: string) {
const value = request.headers[name.toLowerCase()];
return Array.isArray(value) ? value[0] : value;
}
private readCookie(request: Request, cookieName: string) {
const cookieHeader = request.headers.cookie || '';
const cookie = cookieHeader.split(';').find((item) => {
const [key] = item.trim().split('=');
return key === cookieName;
});
if (!cookie) return undefined;
const [, ...value] = cookie.trim().split('=');
try {
return decodeURIComponent(value.join('='));
} catch {
return value.join('=');
}
}
private getForwardableAuthorization(request: Request) {
const authorization = this.readHeader(request, 'authorization');
if (!authorization || this.isLikelyAdminAuthorization(authorization)) {
return undefined;
}
return authorization;
}
private isLikelyAdminAuthorization(authorization: string) {
if (!authorization.startsWith('Bearer ')) return false;
const token = authorization.replace(/^Bearer\s+/i, '');
const [encodedPayload, signature, extra] = token.split('.');
if (!encodedPayload || !signature || extra) return false;
try {
const payload = JSON.parse(
Buffer.from(encodedPayload, 'base64url').toString('utf8'),
);
return payload?.type === 'access' || payload?.type === 'refresh';
} catch {
return false;
}
}
private getWordpressCookie(cookieHeader?: string) {
if (!cookieHeader) return undefined;
// 只透传 WordPress 登录相关 cookie避免把本系统 admin token 泄露给 WordPress。
const cookies = cookieHeader
.split(';')
.map((item) => item.trim())
.filter((item) => {
const [key] = item.split('=');
return WORDPRESS_COOKIE_PREFIXES.some((prefix) =>
key.startsWith(prefix),
);
});
return cookies.length ? cookies.join('; ') : undefined;
}
}
type WordpressPagedQueryDto = WordpressArticleListQueryDto | WordpressTermListQueryDto;

View File

@ -14,6 +14,11 @@ import { DictService } from '../src/admin/dict/dict.service';
import { SaveBodyInterceptor, ToolsService } from '../src/common';
import { MinioClientController } from '../src/minio/minio.controller';
import { MinioClientService } from '../src/minio/minio.service';
import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller';
import { WordpressAuthController } from '../src/wordpress/wordpress-auth.controller';
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
import { WordpressService } from '../src/wordpress/wordpress.service';
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
import {
collectControllerRoutes,
routeKey,
@ -63,6 +68,39 @@ const objectStat = {
lastModified: '2026-05-13T02:30:00.000Z',
};
const wordpressAuthContext = {
authorization: 'Bearer wordpress-client-token',
};
const wordpressUser = {
id: 1,
name: 'WordPress Admin',
slug: 'wordpress-admin',
};
const wordpressLoginResult = {
auth: {
nonce: 'wordpress-rest-nonce',
type: 'cookie',
},
cookie: 'wordpress_logged_in_demo=1',
user: wordpressUser,
};
const wordpressArticle = {
id: 1,
title: {
rendered: 'WordPress 文章',
},
status: 'draft',
};
const wordpressTerm = {
id: 1,
name: 'WordPress 分类',
slug: 'wordpress-category',
};
const componentServiceMock = {
all: jest.fn(),
page: jest.fn(),
@ -102,11 +140,38 @@ const minioServiceMock = {
removeObject: jest.fn(),
};
const wordpressServiceMock = {
getAuthContext: jest.fn(),
loginWithConfiguredAdmin: jest.fn(),
setAuthCookie: jest.fn(),
clearAuthCookie: jest.fn(),
checkAuth: jest.fn(),
articleList: jest.fn(),
articleDetail: jest.fn(),
articleSave: jest.fn(),
articleUpdate: jest.fn(),
articleRemove: jest.fn(),
tagList: jest.fn(),
tagDetail: jest.fn(),
tagSave: jest.fn(),
tagUpdate: jest.fn(),
tagRemove: jest.fn(),
categoryList: jest.fn(),
categoryDetail: jest.fn(),
categorySave: jest.fn(),
categoryUpdate: jest.fn(),
categoryRemove: jest.fn(),
};
const controllerClasses = [
AppController,
ComponentController,
DictController,
MinioClientController,
WordpressAuthController,
WordpressArticleController,
WordpressTagController,
WordpressCategoryController,
];
const controllerRoutes = collectControllerRoutes(controllerClasses);
@ -487,6 +552,434 @@ const routeTestCases: Record<string, RouteTestCase> = {
data: true,
});
},
'GET /wordpress/auth/check': async (server) => {
wordpressServiceMock.checkAuth.mockResolvedValue(wordpressUser);
const response = await request(server)
.get('/wordpress/auth/check')
.expect(200);
expect(wordpressServiceMock.checkAuth).toHaveBeenCalledWith(
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressUser,
});
},
'POST /wordpress/auth/login': async (server) => {
wordpressServiceMock.loginWithConfiguredAdmin.mockResolvedValue(
wordpressLoginResult,
);
const response = await request(server)
.post('/wordpress/auth/login')
.expect(201);
expect(wordpressServiceMock.loginWithConfiguredAdmin).toHaveBeenCalledWith();
expect(wordpressServiceMock.setAuthCookie).toHaveBeenCalledWith(
expect.anything(),
wordpressLoginResult.cookie,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: {
auth: wordpressLoginResult.auth,
user: wordpressUser,
},
});
},
'POST /wordpress/auth/logout': async (server) => {
const response = await request(server)
.post('/wordpress/auth/logout')
.expect(201);
expect(wordpressServiceMock.clearAuthCookie).toHaveBeenCalledWith(
expect.anything(),
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: true,
});
},
'GET /wordpress/article/list': async (server) => {
wordpressServiceMock.articleList.mockResolvedValue({
list: [wordpressArticle],
total: 1,
});
const response = await request(server)
.get('/wordpress/article/list')
.query({
pageNo: 1,
pageSize: 10,
search: '文章',
})
.expect(200);
expect(wordpressServiceMock.articleList).toHaveBeenCalledWith(
{
pageNo: '1',
pageSize: '10',
search: '文章',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: {
list: [wordpressArticle],
total: 1,
},
});
},
'GET /wordpress/article/detail': async (server) => {
wordpressServiceMock.articleDetail.mockResolvedValue(wordpressArticle);
const response = await request(server)
.get('/wordpress/article/detail')
.query({ id: 1 })
.expect(200);
expect(wordpressServiceMock.articleDetail).toHaveBeenCalledWith(
'1',
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressArticle,
});
},
'POST /wordpress/article/save': async (server) => {
wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle);
const response = await request(server)
.post('/wordpress/article/save')
.send({
id: 999,
title: 'WordPress 文章',
content: '文章内容',
})
.expect(200);
expect(wordpressServiceMock.articleSave).toHaveBeenCalledWith(
{
title: 'WordPress 文章',
content: '文章内容',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressArticle,
});
},
'POST /wordpress/article/update': async (server) => {
wordpressServiceMock.articleUpdate.mockResolvedValue(wordpressArticle);
const response = await request(server)
.post('/wordpress/article/update')
.send({
id: 1,
title: 'WordPress 文章',
})
.expect(200);
expect(wordpressServiceMock.articleUpdate).toHaveBeenCalledWith(
{
id: 1,
title: 'WordPress 文章',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressArticle,
});
},
'POST /wordpress/article/remove': async (server) => {
wordpressServiceMock.articleRemove.mockResolvedValue(true);
const response = await request(server)
.post('/wordpress/article/remove')
.query({
id: 1,
force: 'false',
})
.expect(200);
expect(wordpressServiceMock.articleRemove).toHaveBeenCalledWith(
'1',
false,
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: true,
});
},
'GET /wordpress/tag/list': async (server) => {
wordpressServiceMock.tagList.mockResolvedValue({
list: [wordpressTerm],
total: 1,
});
const response = await request(server)
.get('/wordpress/tag/list')
.query({
pageNo: 1,
pageSize: 10,
search: '分类',
})
.expect(200);
expect(wordpressServiceMock.tagList).toHaveBeenCalledWith(
{
pageNo: '1',
pageSize: '10',
search: '分类',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: {
list: [wordpressTerm],
total: 1,
},
});
},
'GET /wordpress/tag/detail': async (server) => {
wordpressServiceMock.tagDetail.mockResolvedValue(wordpressTerm);
const response = await request(server)
.get('/wordpress/tag/detail')
.query({ id: 1 })
.expect(200);
expect(wordpressServiceMock.tagDetail).toHaveBeenCalledWith(
'1',
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/tag/save': async (server) => {
wordpressServiceMock.tagSave.mockResolvedValue(wordpressTerm);
const response = await request(server)
.post('/wordpress/tag/save')
.send({
id: 999,
name: 'WordPress 标签',
})
.expect(200);
expect(wordpressServiceMock.tagSave).toHaveBeenCalledWith(
{
name: 'WordPress 标签',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/tag/update': async (server) => {
wordpressServiceMock.tagUpdate.mockResolvedValue(wordpressTerm);
const response = await request(server)
.post('/wordpress/tag/update')
.send({
id: 1,
name: 'WordPress 标签',
})
.expect(200);
expect(wordpressServiceMock.tagUpdate).toHaveBeenCalledWith(
{
id: 1,
name: 'WordPress 标签',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/tag/remove': async (server) => {
wordpressServiceMock.tagRemove.mockResolvedValue(true);
const response = await request(server)
.post('/wordpress/tag/remove')
.query({ id: 1 })
.expect(200);
expect(wordpressServiceMock.tagRemove).toHaveBeenCalledWith(
'1',
true,
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: true,
});
},
'GET /wordpress/category/list': async (server) => {
wordpressServiceMock.categoryList.mockResolvedValue({
list: [wordpressTerm],
total: 1,
});
const response = await request(server)
.get('/wordpress/category/list')
.query({
pageNo: 1,
pageSize: 10,
search: '分类',
})
.expect(200);
expect(wordpressServiceMock.categoryList).toHaveBeenCalledWith(
{
pageNo: '1',
pageSize: '10',
search: '分类',
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: {
list: [wordpressTerm],
total: 1,
},
});
},
'GET /wordpress/category/detail': async (server) => {
wordpressServiceMock.categoryDetail.mockResolvedValue(wordpressTerm);
const response = await request(server)
.get('/wordpress/category/detail')
.query({ id: 1 })
.expect(200);
expect(wordpressServiceMock.categoryDetail).toHaveBeenCalledWith(
'1',
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/category/save': async (server) => {
wordpressServiceMock.categorySave.mockResolvedValue(wordpressTerm);
const response = await request(server)
.post('/wordpress/category/save')
.send({
id: 999,
name: 'WordPress 分类',
parent: 0,
})
.expect(200);
expect(wordpressServiceMock.categorySave).toHaveBeenCalledWith(
{
name: 'WordPress 分类',
parent: 0,
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/category/update': async (server) => {
wordpressServiceMock.categoryUpdate.mockResolvedValue(wordpressTerm);
const response = await request(server)
.post('/wordpress/category/update')
.send({
id: 1,
name: 'WordPress 分类',
parent: 0,
})
.expect(200);
expect(wordpressServiceMock.categoryUpdate).toHaveBeenCalledWith(
{
id: 1,
name: 'WordPress 分类',
parent: 0,
},
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: wordpressTerm,
});
},
'POST /wordpress/category/remove': async (server) => {
wordpressServiceMock.categoryRemove.mockResolvedValue(true);
const response = await request(server)
.post('/wordpress/category/remove')
.query({ id: 1 })
.expect(200);
expect(wordpressServiceMock.categoryRemove).toHaveBeenCalledWith(
'1',
true,
wordpressAuthContext,
);
expect(response.body).toEqual({
code: 200,
msg: '操作成功',
data: true,
});
},
};
describe('KT Template Online API (e2e)', () => {
@ -516,6 +1009,10 @@ describe('KT Template Online API (e2e)', () => {
provide: MinioClientService,
useValue: minioServiceMock,
},
{
provide: WordpressService,
useValue: wordpressServiceMock,
},
{
provide: APP_INTERCEPTOR,
useClass: SaveBodyInterceptor,
@ -533,6 +1030,7 @@ describe('KT Template Online API (e2e)', () => {
id: '2041739550026043001',
username: 'admin',
});
wordpressServiceMock.getAuthContext.mockReturnValue(wordpressAuthContext);
});
afterAll(async () => {
@ -576,7 +1074,7 @@ describe('KT Template Online API (e2e)', () => {
});
});
it('protects dict and minio endpoints with jwt auth', async () => {
it('protects dict, minio and wordpress endpoints with jwt auth', async () => {
authServiceMock.currentUser.mockRejectedValue(unauthorizedException());
await request(app.getHttpServer())
@ -592,5 +1090,14 @@ describe('KT Template Online API (e2e)', () => {
await request(app.getHttpServer()).get('/minio/check').expect(401);
expect(minioServiceMock.checkConnection).not.toHaveBeenCalled();
jest.clearAllMocks();
authServiceMock.currentUser.mockRejectedValue(unauthorizedException());
await request(app.getHttpServer())
.get('/wordpress/auth/check')
.expect(401);
expect(wordpressServiceMock.checkAuth).not.toHaveBeenCalled();
});
});