mirror of
https://github.com/KwiTsukasa/kt-template-online-api.git
synced 2026-05-27 15:44:54 +08:00
feat(api): 接入WordPress内容管理认证
This commit is contained in:
parent
a920a92ad0
commit
276b82b8d8
@ -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
141
API.md
@ -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`
|
||||
|
||||
13
README.md
13
README.md
@ -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` 编辑组件。
|
||||
|
||||
@ -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,
|
||||
|
||||
114
src/wordpress/wordpress-article.controller.ts
Normal file
114
src/wordpress/wordpress-article.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
65
src/wordpress/wordpress-auth.controller.ts
Normal file
65
src/wordpress/wordpress-auth.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
114
src/wordpress/wordpress-category.controller.ts
Normal file
114
src/wordpress/wordpress-category.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
114
src/wordpress/wordpress-tag.controller.ts
Normal file
114
src/wordpress/wordpress-tag.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
150
src/wordpress/wordpress.dto.ts
Normal file
150
src/wordpress/wordpress.dto.ts
Normal 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;
|
||||
}
|
||||
20
src/wordpress/wordpress.module.ts
Normal file
20
src/wordpress/wordpress.module.ts
Normal 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 {}
|
||||
748
src/wordpress/wordpress.service.ts
Normal file
748
src/wordpress/wordpress.service.ts
Normal 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;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user