diff --git a/.env.example b/.env.example index 74fa34e..1d9a749 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/API.md b/API.md index 4947b7a..0162350 100644 --- a/API.md +++ b/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 ` | +| `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` diff --git a/README.md b/README.md index 64b83c9..d2dfe31 100644 --- a/README.md +++ b/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` 编辑组件。 diff --git a/src/app.module.ts b/src/app.module.ts index cd8918a..0108e40 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/wordpress/wordpress-article.controller.ts b/src/wordpress/wordpress-article.controller.ts new file mode 100644 index 0000000..237a317 --- /dev/null +++ b/src/wordpress/wordpress-article.controller.ts @@ -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)); + } +} diff --git a/src/wordpress/wordpress-auth.controller.ts b/src/wordpress/wordpress-auth.controller.ts new file mode 100644 index 0000000..f4aa2bc --- /dev/null +++ b/src/wordpress/wordpress-auth.controller.ts @@ -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)); + } +} diff --git a/src/wordpress/wordpress-category.controller.ts b/src/wordpress/wordpress-category.controller.ts new file mode 100644 index 0000000..a53b0bb --- /dev/null +++ b/src/wordpress/wordpress-category.controller.ts @@ -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)); + } +} diff --git a/src/wordpress/wordpress-tag.controller.ts b/src/wordpress/wordpress-tag.controller.ts new file mode 100644 index 0000000..451d652 --- /dev/null +++ b/src/wordpress/wordpress-tag.controller.ts @@ -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)); + } +} diff --git a/src/wordpress/wordpress.dto.ts b/src/wordpress/wordpress.dto.ts new file mode 100644 index 0000000..7129f19 --- /dev/null +++ b/src/wordpress/wordpress.dto.ts @@ -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; +} diff --git a/src/wordpress/wordpress.module.ts b/src/wordpress/wordpress.module.ts new file mode 100644 index 0000000..39172a1 --- /dev/null +++ b/src/wordpress/wordpress.module.ts @@ -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 {} diff --git a/src/wordpress/wordpress.service.ts b/src/wordpress/wordpress.service.ts new file mode 100644 index 0000000..20ad5b2 --- /dev/null +++ b/src/wordpress/wordpress.service.ts @@ -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; + method?: 'GET' | 'POST' | 'DELETE'; + query?: Record; +}; + +type WordpressResponse = { + 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('WORDPRESS_ADMIN_USERNAME'); + const password = this.configService.get('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('/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(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( + path: string, + options: WordpressRequestOptions, + ): Promise> { + 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) { + 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 = { + 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('ADMIN_COOKIE_SECURE') === 'true'; + + return { + httpOnly: true, + path: '/', + sameSite: secure ? ('none' as const) : ('lax' as const), + secure, + }; + } + + private getUrl(path: string, query?: Record) { + const baseUrl = this.configService.get('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) { + 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) { + 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) { + return Object.entries(payload).reduce>( + (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(/]*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; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index fc6e913..4630db9 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -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 = { 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(); }); });