Compare commits
19
Commits
5de299c0c1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac8be4dd0 | ||
|
|
de01ffd7c7 | ||
|
|
91b5cce39b | ||
|
|
ef6380395c | ||
|
|
24cb8e9843 | ||
|
|
730b77448e | ||
|
|
0d11d2bd8a | ||
|
|
769973517b | ||
|
|
d2692e6a11 | ||
|
|
0f8abaec7f | ||
|
|
18b3f7b9da | ||
|
|
16a08bf280 | ||
|
|
6951ea1373 | ||
|
|
2c70ce4cdf | ||
|
|
db5b553a93 | ||
|
|
9461fc53f3 | ||
|
|
fab90132b0 | ||
|
|
bff2c4a4c7 | ||
|
|
23c8eba867 |
@@ -21,6 +21,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
"find-up": "^7.0.0",
|
"find-up": "^7.0.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -1086,7 +1087,7 @@
|
|||||||
|
|
||||||
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
|
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="],
|
||||||
|
|
||||||
"fastify": ["fastify@5.3.3", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-nCBiBCw9q6jPx+JJNVgO8JVnTXeUyrGcyTKPQikRkA/PanrFcOIo4R+ZnLeOLPZPGgzjomqfVarzE0kYx7qWiQ=="],
|
"fastify": ["fastify@5.4.0", "https://registry.npmmirror.com/fastify/-/fastify-5.4.0.tgz", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-I4dVlUe+WNQAhKSyv15w+dwUh2EPiEl4X2lGYMmNSgF83WzTMAPKGdWEv5tPsCQOb+SOZwz8Vlta2vF+OeDgRw=="],
|
||||||
|
|
||||||
"fastify-plugin": ["fastify-plugin@5.0.1", "", {}, "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ=="],
|
"fastify-plugin": ["fastify-plugin@5.0.1", "", {}, "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ=="],
|
||||||
|
|
||||||
@@ -2142,6 +2143,8 @@
|
|||||||
|
|
||||||
"@nestjs/jwt/@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg=="],
|
"@nestjs/jwt/@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg=="],
|
||||||
|
|
||||||
|
"@nestjs/platform-fastify/fastify": ["fastify@5.3.3", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-nCBiBCw9q6jPx+JJNVgO8JVnTXeUyrGcyTKPQikRkA/PanrFcOIo4R+ZnLeOLPZPGgzjomqfVarzE0kYx7qWiQ=="],
|
||||||
|
|
||||||
"@nestjs/schematics/@angular-devkit/core": ["@angular-devkit/core@19.2.6", "", { "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.2", "rxjs": "7.8.1", "source-map": "0.7.4" }, "peerDependencies": { "chokidar": "^4.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ=="],
|
"@nestjs/schematics/@angular-devkit/core": ["@angular-devkit/core@19.2.6", "", { "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.2", "rxjs": "7.8.1", "source-map": "0.7.4" }, "peerDependencies": { "chokidar": "^4.0.0" }, "optionalPeers": ["chokidar"] }, "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ=="],
|
||||||
|
|
||||||
"@nestjs/schematics/@angular-devkit/schematics": ["@angular-devkit/schematics@19.2.6", "", { "dependencies": { "@angular-devkit/core": "19.2.6", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", "rxjs": "7.8.1" } }, "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ=="],
|
"@nestjs/schematics/@angular-devkit/schematics": ["@angular-devkit/schematics@19.2.6", "", { "dependencies": { "@angular-devkit/core": "19.2.6", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", "rxjs": "7.8.1" } }, "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ=="],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "^6.7.3",
|
"@casl/ability": "^6.7.3",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
"@nestjs/common": "^11.1.3",
|
"@nestjs/common": "^11.1.3",
|
||||||
"@nestjs/core": "^11.1.3",
|
"@nestjs/core": "^11.1.3",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/typeorm": "^11.0.0",
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"bullmq": "^5.56.0",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -40,12 +42,16 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
|
"email-templates": "^12.0.3",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
"find-up": "^7.0.0",
|
"find-up": "^7.0.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"meilisearch": "^0.51.0",
|
"meilisearch": "^0.51.0",
|
||||||
"mysql2": "^3.14.1",
|
"mysql2": "^3.14.1",
|
||||||
|
"nodemailer": "^7.0.4",
|
||||||
"ora": "^8.2.0",
|
"ora": "^8.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -55,6 +61,7 @@
|
|||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
"tencentcloud-sdk-nodejs": "^4.1.67",
|
||||||
"typeorm": "^0.3.24",
|
"typeorm": "^0.3.24",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.15",
|
||||||
@@ -72,12 +79,14 @@
|
|||||||
"@swc/cli": "^0.7.7",
|
"@swc/cli": "^0.7.7",
|
||||||
"@swc/core": "^1.12.1",
|
"@swc/core": "^1.12.1",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/email-templates": "^10.0.4",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/lodash": "^4.17.17",
|
"@types/lodash": "^4.17.17",
|
||||||
"@types/node": "^24.0.1",
|
"@types/node": "^24.0.1",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
|||||||
Generated
+2410
-623
File diff suppressed because it is too large
Load Diff
+20
-12
@@ -1,11 +1,14 @@
|
|||||||
import { Configure } from '@/modules/config/configure';
|
import { Configure } from '@/modules/config/configure';
|
||||||
import { ConfigureFactory } from '@/modules/config/types';
|
import { ConfigureFactory } from '@/modules/config/types';
|
||||||
import * as contentControllers from '@/modules/content/controllers';
|
import { createContentApi } from '@/modules/content/routes';
|
||||||
|
import { createRbacApi } from '@/modules/rbac/routes';
|
||||||
import { ApiConfig, VersionOption } from '@/modules/restful/types';
|
import { ApiConfig, VersionOption } from '@/modules/restful/types';
|
||||||
import { createUserApi } from '@/modules/user/routes';
|
import { createUserApi } from '@/modules/user/routes';
|
||||||
|
|
||||||
export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
||||||
|
const contentApi = createContentApi();
|
||||||
const userApi = createUserApi();
|
const userApi = createUserApi();
|
||||||
|
const rbacApi = createRbacApi();
|
||||||
return {
|
return {
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
@@ -14,21 +17,26 @@ export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
|||||||
controllers: [],
|
controllers: [],
|
||||||
doc: {
|
doc: {
|
||||||
description: 'app name desc',
|
description: 'app name desc',
|
||||||
|
tags: [...contentApi.tags.app, ...rbacApi.tags.app, ...userApi.tags.app],
|
||||||
|
},
|
||||||
|
children: [...contentApi.routes.app, ...rbacApi.routes.app, ...userApi.routes.app],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'manager',
|
||||||
|
path: 'manager',
|
||||||
|
controllers: [],
|
||||||
|
doc: {
|
||||||
|
description: '后台管理接口',
|
||||||
tags: [
|
tags: [
|
||||||
{ name: '分类操作', description: '对分类进行CRUD操作' },
|
...contentApi.tags.manager,
|
||||||
{ name: '标签操作', description: '对标签进行CRUD操作' },
|
...rbacApi.tags.manager,
|
||||||
{ name: '文章操作', description: '对文章进行CRUD操作' },
|
...userApi.tags.manager,
|
||||||
{ name: '评论操作', description: '对评论进行CRUD操作' },
|
|
||||||
...userApi.tags.app,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
...contentApi.routes.manager,
|
||||||
name: 'app.content',
|
...rbacApi.routes.manager,
|
||||||
path: 'content',
|
...userApi.routes.manager,
|
||||||
controllers: Object.values(contentControllers),
|
|
||||||
},
|
|
||||||
...userApi.routes.app,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { ContentFactory } from '@/modules/database/factories/content.factory';
|
|||||||
import ContentSeeder from '@/modules/database/seeders/content.seeder';
|
import ContentSeeder from '@/modules/database/seeders/content.seeder';
|
||||||
|
|
||||||
export const database = createDBConfig((configure) => ({
|
export const database = createDBConfig((configure) => ({
|
||||||
common: { synchronize: true },
|
common: {
|
||||||
|
synchronize: true,
|
||||||
|
// 启用详细日志以便调试 SQL 错误
|
||||||
|
logging: configure.env.get('NODE_ENV') === 'development' ? ['error', 'query'] : ['error'],
|
||||||
|
// 启用最大日志记录
|
||||||
|
maxQueryExecutionTime: 1000,
|
||||||
|
},
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
type: 'mysql',
|
type: 'mysql',
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ export * from './content.config';
|
|||||||
export * from './app.config';
|
export * from './app.config';
|
||||||
export * from './meili.config';
|
export * from './meili.config';
|
||||||
export * from './api.config';
|
export * from './api.config';
|
||||||
|
export * from './sms.config';
|
||||||
|
export * from './smtp.config';
|
||||||
|
export * from './redis.config';
|
||||||
|
export * from './queue.config';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createMeiliConfig } from '../modules/meilisearch/config';
|
import { createMeiliConfig } from '@/modules/meilisearch/config';
|
||||||
|
|
||||||
export const meili = createMeiliConfig((configure) => [
|
export const meili = createMeiliConfig((configure) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { QueueOptions } from '@/modules/core/types';
|
||||||
|
|
||||||
|
export const queue: () => QueueOptions = () => ({
|
||||||
|
redis: 'default',
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { RedisOptions } from '@/modules/core/types';
|
||||||
|
|
||||||
|
export const redis: () => RedisOptions = () => ({
|
||||||
|
host: '192.168.50.137',
|
||||||
|
port: 6379,
|
||||||
|
password: '123456&Qw',
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Configure } from '@/modules/config/configure';
|
||||||
|
import { SmsOptions } from '@/modules/core/types';
|
||||||
|
|
||||||
|
export const sms: (configure: Configure) => SmsOptions = (configure) => ({
|
||||||
|
sign: configure.env.get('SMS_CLOUD_SING', 'liuyi'),
|
||||||
|
region: configure.env.get('SMS_CLOUD_REGION', 'ap-guangzhou'),
|
||||||
|
appid: configure.env.get('SMS_CLOUD_APPID', 'app-id'),
|
||||||
|
secretId: configure.env.get('SMS_CLOUD_ID', 'your-secret-id'),
|
||||||
|
secretKey: configure.env.get('SMS_CLOUD_KEY', 'your-secret-key'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { Configure } from '@/modules/config/configure';
|
||||||
|
import { SmtpOptions } from '@/modules/core/types';
|
||||||
|
|
||||||
|
export const smtp: (configure: Configure) => SmtpOptions = (configure) => ({
|
||||||
|
host: configure.env.get('SMTP_HOST', 'localhost'),
|
||||||
|
user: configure.env.get('SMTP_USER', 'test'),
|
||||||
|
password: configure.env.get('SMTP_PASSWORD', ''),
|
||||||
|
from: configure.env.get('SMTP_FROM', '平克小站<support@localhost>'),
|
||||||
|
port: configure.env.get('SMTP_PORT', (v) => Number(v), 25),
|
||||||
|
secure: configure.env.get('SMTP_SSL', (v) => JSON.parse(v), false),
|
||||||
|
// Email模板路径
|
||||||
|
resource: path.resolve(__dirname, '../../assets/emails'),
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
import { createUserConfig } from '@/modules/user/config';
|
import { createUserConfig } from '@/modules/user/config';
|
||||||
|
|
||||||
export const user = createUserConfig(() => ({}));
|
export const user = createUserConfig(() => ({
|
||||||
|
hash: 10,
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
import * as entities from '@/modules/content/entities';
|
import * as entities from '@/modules/content/entities';
|
||||||
|
import { ContentRbac } from '@/modules/content/rbac';
|
||||||
import * as repositories from '@/modules/content/repositories';
|
import * as repositories from '@/modules/content/repositories';
|
||||||
import * as services from '@/modules/content/services';
|
import * as services from '@/modules/content/services';
|
||||||
import { SearchService } from '@/modules/content/services';
|
import { SearchService } from '@/modules/content/services';
|
||||||
@@ -12,6 +13,8 @@ import { DatabaseModule } from '@/modules/database/database.module';
|
|||||||
|
|
||||||
import { addEntities, addSubscribers } from '@/modules/database/utils';
|
import { addEntities, addSubscribers } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
import { Configure } from '../config/configure';
|
||||||
|
|
||||||
import { defauleContentConfig } from './config';
|
import { defauleContentConfig } from './config';
|
||||||
@@ -23,6 +26,7 @@ export class ContentModule {
|
|||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||||
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
|
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
|
||||||
const providers: ModuleMetadata['providers'] = [
|
const providers: ModuleMetadata['providers'] = [
|
||||||
|
ContentRbac,
|
||||||
...Object.values(services),
|
...Object.values(services),
|
||||||
...(await addSubscribers(configure, Object.values(subscribers))),
|
...(await addSubscribers(configure, Object.values(subscribers))),
|
||||||
{
|
{
|
||||||
@@ -32,6 +36,7 @@ export class ContentModule {
|
|||||||
repositories.CategoryRepository,
|
repositories.CategoryRepository,
|
||||||
repositories.TagRepository,
|
repositories.TagRepository,
|
||||||
services.CategoryService,
|
services.CategoryService,
|
||||||
|
UserRepository,
|
||||||
{ token: services.SearchService, optional: true },
|
{ token: services.SearchService, optional: true },
|
||||||
],
|
],
|
||||||
useFactory(
|
useFactory(
|
||||||
@@ -39,6 +44,7 @@ export class ContentModule {
|
|||||||
categoryRepository: repositories.CategoryRepository,
|
categoryRepository: repositories.CategoryRepository,
|
||||||
tagRepository: repositories.TagRepository,
|
tagRepository: repositories.TagRepository,
|
||||||
categoryService: services.CategoryService,
|
categoryService: services.CategoryService,
|
||||||
|
userRepository: UserRepository,
|
||||||
searchService: SearchService,
|
searchService: SearchService,
|
||||||
) {
|
) {
|
||||||
return new PostService(
|
return new PostService(
|
||||||
@@ -46,6 +52,7 @@ export class ContentModule {
|
|||||||
categoryRepository,
|
categoryRepository,
|
||||||
categoryService,
|
categoryService,
|
||||||
tagRepository,
|
tagRepository,
|
||||||
|
userRepository,
|
||||||
searchService,
|
searchService,
|
||||||
config.searchType,
|
config.searchType,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
import {
|
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
SerializeOptions,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -17,11 +6,12 @@ import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
|||||||
|
|
||||||
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||||
|
|
||||||
|
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
import { ContentModule } from '../content.module';
|
||||||
import { CreateCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
|
|
||||||
import { CategoryService } from '../services';
|
import { CategoryService } from '../services';
|
||||||
|
|
||||||
@ApiTags('Category Operate')
|
@ApiTags('分类查询')
|
||||||
@Depends(ContentModule)
|
@Depends(ContentModule)
|
||||||
@Controller('category')
|
@Controller('category')
|
||||||
export class CategoryController {
|
export class CategoryController {
|
||||||
@@ -31,6 +21,7 @@ export class CategoryController {
|
|||||||
* Search category tree
|
* Search category tree
|
||||||
*/
|
*/
|
||||||
@Get('tree')
|
@Get('tree')
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['category-tree'] })
|
@SerializeOptions({ groups: ['category-tree'] })
|
||||||
async tree() {
|
async tree() {
|
||||||
return this.service.findTrees();
|
return this.service.findTrees();
|
||||||
@@ -41,6 +32,7 @@ export class CategoryController {
|
|||||||
* @param options
|
* @param options
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['category-list'] })
|
@SerializeOptions({ groups: ['category-list'] })
|
||||||
async list(
|
async list(
|
||||||
@Query()
|
@Query()
|
||||||
@@ -54,32 +46,9 @@ export class CategoryController {
|
|||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
return this.service.detail(id);
|
return this.service.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
|
||||||
async store(
|
|
||||||
@Body()
|
|
||||||
data: CreateCategoryDto,
|
|
||||||
) {
|
|
||||||
return this.service.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch()
|
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
|
||||||
async update(
|
|
||||||
@Body()
|
|
||||||
data: UpdateCategoryDto,
|
|
||||||
) {
|
|
||||||
return this.service.update(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@SerializeOptions({ groups: ['category-detail'] })
|
|
||||||
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
|
||||||
return this.service.delete([id]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { In } from 'typeorm';
|
||||||
|
|
||||||
|
import { CommentEntity } from '@/modules/content/entities';
|
||||||
|
import { CommentRepository } from '@/modules/content/repositories';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { checkOwnerPermission } from '@/modules/rbac/utils';
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||||
|
|
||||||
|
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
|
||||||
|
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
import { ContentModule } from '../content.module';
|
||||||
import {
|
import {
|
||||||
CreateCommentDto,
|
CreateCommentDto,
|
||||||
@@ -11,18 +26,41 @@ import {
|
|||||||
} from '../dtos/comment.dto';
|
} from '../dtos/comment.dto';
|
||||||
import { CommentService } from '../services';
|
import { CommentService } from '../services';
|
||||||
|
|
||||||
|
const permissions: Record<'create' | 'owner', PermissionChecker> = {
|
||||||
|
create: async (ab) => ab.can(PermissionAction.CREATE, CommentEntity.name),
|
||||||
|
owner: async (ab, ref, request) =>
|
||||||
|
checkOwnerPermission(ab, {
|
||||||
|
request,
|
||||||
|
getData: async (items) =>
|
||||||
|
ref.get(CommentRepository, { strict: false }).find({
|
||||||
|
relations: ['user'],
|
||||||
|
where: { id: In(items) },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('评论操作')
|
||||||
@Depends(ContentModule)
|
@Depends(ContentModule)
|
||||||
@Controller('comment')
|
@Controller('comment')
|
||||||
export class CommentController {
|
export class CommentController {
|
||||||
constructor(protected service: CommentService) {}
|
constructor(protected service: CommentService) {}
|
||||||
|
/**
|
||||||
|
* 查询评论树
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
@Get('tree')
|
@Get('tree')
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['comment-tree'] })
|
@SerializeOptions({ groups: ['comment-tree'] })
|
||||||
async tree(@Query() options: QueryCommentTreeDto) {
|
async tree(@Query() options: QueryCommentTreeDto) {
|
||||||
return this.service.findTrees(options);
|
return this.service.findTrees(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询评论列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['comment-list'] })
|
@SerializeOptions({ groups: ['comment-list'] })
|
||||||
async list(
|
async list(
|
||||||
@Query()
|
@Query()
|
||||||
@@ -31,13 +69,26 @@ export class CommentController {
|
|||||||
return this.service.paginate(options);
|
return this.service.paginate(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增评论
|
||||||
|
* @param data
|
||||||
|
* @param author
|
||||||
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permission(permissions.create)
|
||||||
@SerializeOptions({ groups: ['comment-detail'] })
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
async store(@Body() data: CreateCommentDto) {
|
async store(@Body() data: CreateCommentDto, @RequestUser() author: ClassToPlain<UserEntity>) {
|
||||||
return this.service.create(data);
|
return this.service.create(data, author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除评论
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
@Delete()
|
@Delete()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permission(permissions.owner)
|
||||||
@SerializeOptions({ groups: ['comment-detail'] })
|
@SerializeOptions({ groups: ['comment-detail'] })
|
||||||
async delete(@Body() data: DeleteCommentDto) {
|
async delete(@Body() data: DeleteCommentDto) {
|
||||||
return this.service.delete(data.ids);
|
return this.service.delete(data.ids);
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ContentModule } from '@/modules/content/content.module';
|
||||||
|
import { CreateCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos/category.dto';
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities';
|
||||||
|
import { CategoryService } from '@/modules/content/services';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, CategoryEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('分类管理')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Depends(ContentModule)
|
||||||
|
@Controller('category')
|
||||||
|
export class CategoryController {
|
||||||
|
constructor(protected service: CategoryService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search category tree
|
||||||
|
*/
|
||||||
|
@Get('tree')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-tree'] })
|
||||||
|
async tree() {
|
||||||
|
return this.service.findTrees();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询分类列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-list'] })
|
||||||
|
async list(
|
||||||
|
@Query()
|
||||||
|
options: PaginateDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询分类明细
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async store(
|
||||||
|
@Body()
|
||||||
|
data: CreateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async update(
|
||||||
|
@Body()
|
||||||
|
data: UpdateCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['category-detail'] })
|
||||||
|
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.delete([id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Query, SerializeOptions } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ContentModule } from '@/modules/content/content.module';
|
||||||
|
import { QueryCommentDto } from '@/modules/content/dtos/comment.dto';
|
||||||
|
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
||||||
|
import { CommentEntity } from '@/modules/content/entities';
|
||||||
|
import { CommentService } from '@/modules/content/services';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, CommentEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('评论管理')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Depends(ContentModule)
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentController {
|
||||||
|
constructor(protected service: CommentService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询评论列表
|
||||||
|
* @param query
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['comment-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async list(
|
||||||
|
@Query()
|
||||||
|
query: QueryCommentDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除评论
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@SerializeOptions({ groups: ['comment-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async delete(
|
||||||
|
@Body()
|
||||||
|
data: DeleteDto,
|
||||||
|
) {
|
||||||
|
const { ids } = data;
|
||||||
|
return this.service.delete(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './category.controller';
|
||||||
|
export * from './tag.controller';
|
||||||
|
export * from './post.controller';
|
||||||
|
export * from './comment.controller';
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { ContentModule } from '@/modules/content/content.module';
|
||||||
|
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
||||||
|
import { PostEntity } from '@/modules/content/entities';
|
||||||
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
|
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '../../dtos/post.dto';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, PostEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('文章管理')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Depends(ContentModule)
|
||||||
|
@Controller('posts')
|
||||||
|
export class PostController {
|
||||||
|
constructor(protected service: PostService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文章列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async manageList(
|
||||||
|
@Query()
|
||||||
|
options: QueryPostDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文章详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async manageDetail(
|
||||||
|
@Param('id', new ParseUUIDPipe())
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增文章
|
||||||
|
* @param author
|
||||||
|
* @param data
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async storeManage(
|
||||||
|
@Body()
|
||||||
|
{ author, ...data }: CreatePostDto,
|
||||||
|
@RequestUser() user: ClassToPlain<UserEntity>,
|
||||||
|
) {
|
||||||
|
return this.service.create(data, {
|
||||||
|
id: isNil(author) ? user.id : author,
|
||||||
|
} as ClassToPlain<UserEntity>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async manageUpdate(
|
||||||
|
@Body()
|
||||||
|
data: UpdatePostDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async manageDelete(
|
||||||
|
@Body()
|
||||||
|
data: DeleteWithTrashDto,
|
||||||
|
) {
|
||||||
|
const { ids, trash } = data;
|
||||||
|
return this.service.delete(ids, trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量恢复文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch('restore')
|
||||||
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async manageRestore(
|
||||||
|
@Body()
|
||||||
|
data: RestoreDto,
|
||||||
|
) {
|
||||||
|
const { ids } = data;
|
||||||
|
return this.service.restore(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ContentModule } from '@/modules/content/content.module';
|
||||||
|
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
||||||
|
import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
|
||||||
|
import { TagEntity } from '@/modules/content/entities';
|
||||||
|
import { TagService } from '@/modules/content/services';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) => ab.can(PermissionAction.MANAGE, TagEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('标签管理')
|
||||||
|
@Depends(ContentModule)
|
||||||
|
@Controller('tag')
|
||||||
|
export class TagController {
|
||||||
|
constructor(protected service: TagService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询标签列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({})
|
||||||
|
async list(
|
||||||
|
@Query()
|
||||||
|
options: PaginateDto,
|
||||||
|
) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询标签详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({})
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加新标签
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({})
|
||||||
|
async store(
|
||||||
|
@Body()
|
||||||
|
data: CreateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新标签
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({})
|
||||||
|
async update(
|
||||||
|
@Body()
|
||||||
|
data: UpdateTagDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除标签
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({})
|
||||||
|
async delete(@Body() data: DeleteDto) {
|
||||||
|
return this.service.delete(data.ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,64 +11,179 @@ import {
|
|||||||
SerializeOptions,
|
SerializeOptions,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FrontendCreatePostDto,
|
||||||
|
FrontendQueryPostDto,
|
||||||
|
OwnerQueryPostDto,
|
||||||
|
OwnerUpdatePostDto,
|
||||||
|
} from '@/modules/content/dtos/post.dto';
|
||||||
|
import { PostEntity } from '@/modules/content/entities';
|
||||||
|
import { PostRepository } from '@/modules/content/repositories';
|
||||||
import { PostService } from '@/modules/content/services/post.service';
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
|
|
||||||
|
import { SelectTrashMode } from '@/modules/database/constants';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { checkOwnerPermission } from '@/modules/rbac/utils';
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||||
|
|
||||||
|
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
import { ContentModule } from '../content.module';
|
||||||
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto';
|
import { DeleteWithTrashDto, RestoreDto } from '../dtos/delete.with.trash.dto';
|
||||||
|
|
||||||
|
const permissions: Record<'create' | 'owner', PermissionChecker> = {
|
||||||
|
create: async (ab) => ab.can(PermissionAction.CREATE, PostEntity.name),
|
||||||
|
owner: async (ab, ref, request) =>
|
||||||
|
checkOwnerPermission(ab, {
|
||||||
|
request,
|
||||||
|
getData: async (items) =>
|
||||||
|
ref
|
||||||
|
.get(PostRepository, { strict: false })
|
||||||
|
.find({ relations: ['author'], where: { id: In(items) } }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('文章操作')
|
||||||
@Depends(ContentModule)
|
@Depends(ContentModule)
|
||||||
@Controller('posts')
|
@Controller('posts')
|
||||||
export class PostController {
|
export class PostController {
|
||||||
constructor(private postService: PostService) {}
|
constructor(private postService: PostService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文章列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['post-list'] })
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
async list(
|
async list(
|
||||||
@Query()
|
@Query()
|
||||||
options: QueryPostDto,
|
options: FrontendQueryPostDto,
|
||||||
) {
|
) {
|
||||||
return this.postService.paginate(options);
|
return this.postService.paginate({
|
||||||
|
...options,
|
||||||
|
isPublished: true,
|
||||||
|
trashed: SelectTrashMode.NONE,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询自己发布的文章列表
|
||||||
|
* @param options
|
||||||
|
* @param author
|
||||||
|
*/
|
||||||
|
@Get('owner')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
|
async listOwner(
|
||||||
|
@Query()
|
||||||
|
options: OwnerQueryPostDto,
|
||||||
|
@RequestUser() author: ClassToPlain<UserEntity>,
|
||||||
|
) {
|
||||||
|
return this.postService.paginate({
|
||||||
|
...options,
|
||||||
|
author: author.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询文章详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['post-detail'] })
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
async show(@Param('id', new ParseUUIDPipe()) id: string) {
|
async show(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
return this.postService.detail(id);
|
return this.postService.detail(id, async (qb) =>
|
||||||
|
qb.andWhere({ publishedAt: Not(IsNull()), deletedAt: IsNull() }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
/**
|
||||||
|
* 查询自己发布的文章详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get('owner/:id')
|
||||||
|
@ApiBearerAuth()
|
||||||
@SerializeOptions({ groups: ['post-detail'] })
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permissions.owner)
|
||||||
|
async detailOwner(
|
||||||
|
@Param('id', new ParseUUIDPipe())
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
return this.postService.detail(id, async (qb) => qb.withDeleted());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增文章
|
||||||
|
* @param data
|
||||||
|
* @param author
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permissions.create)
|
||||||
async store(
|
async store(
|
||||||
@Body()
|
@Body()
|
||||||
data: CreatePostDto,
|
data: FrontendCreatePostDto,
|
||||||
|
@RequestUser() author: ClassToPlain<UserEntity>,
|
||||||
) {
|
) {
|
||||||
return this.postService.create(data);
|
return this.postService.create(data, author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新自己发布的文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
@Patch()
|
@Patch()
|
||||||
|
@ApiBearerAuth()
|
||||||
@SerializeOptions({ groups: ['post-detail'] })
|
@SerializeOptions({ groups: ['post-detail'] })
|
||||||
|
@Permission(permissions.owner)
|
||||||
async update(
|
async update(
|
||||||
@Body()
|
@Body()
|
||||||
data: UpdatePostDto,
|
data: OwnerUpdatePostDto,
|
||||||
) {
|
) {
|
||||||
return this.postService.update(data);
|
return this.postService.update(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除自己发布的文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
@Delete()
|
@Delete()
|
||||||
@SerializeOptions({ groups: ['post-detail'] })
|
@ApiBearerAuth()
|
||||||
async delete(@Body() data: DeleteWithTrashDto) {
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
return this.postService.delete(data.ids, data.trash);
|
@Permission(permissions.owner)
|
||||||
|
async delete(
|
||||||
|
@Body()
|
||||||
|
data: DeleteWithTrashDto,
|
||||||
|
) {
|
||||||
|
const { ids, trash } = data;
|
||||||
|
return this.postService.delete(ids, trash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量恢复自己发布的文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
@Patch('restore')
|
@Patch('restore')
|
||||||
@SerializeOptions({ groups: ['post-detail'] })
|
@ApiBearerAuth()
|
||||||
|
@SerializeOptions({ groups: ['post-list'] })
|
||||||
|
@Permission(permissions.owner)
|
||||||
async restore(
|
async restore(
|
||||||
@Body()
|
@Body()
|
||||||
data: RestoreDto,
|
data: RestoreDto,
|
||||||
) {
|
) {
|
||||||
return this.postService.restore(data.ids);
|
const { ids } = data;
|
||||||
|
return this.postService.restore(ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
import {
|
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
SerializeOptions,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
import { DeleteDto } from '@/modules/content/dtos/delete.dto';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||||
|
|
||||||
|
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||||
|
|
||||||
import { ContentModule } from '../content.module';
|
import { ContentModule } from '../content.module';
|
||||||
import { CreateTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
|
||||||
import { TagService } from '../services';
|
import { TagService } from '../services';
|
||||||
|
|
||||||
|
@ApiTags('标签查询')
|
||||||
@Depends(ContentModule)
|
@Depends(ContentModule)
|
||||||
@Controller('tag')
|
@Controller('tag')
|
||||||
export class TagController {
|
export class TagController {
|
||||||
constructor(protected service: TagService) {}
|
constructor(protected service: TagService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询标签列表
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({})
|
@SerializeOptions({})
|
||||||
async list(
|
async list(
|
||||||
@Query()
|
@Query()
|
||||||
@@ -35,33 +31,14 @@ export class TagController {
|
|||||||
return this.service.paginate(options);
|
return this.service.paginate(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询标签详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Guest()
|
||||||
@SerializeOptions({})
|
@SerializeOptions({})
|
||||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
return this.service.detail(id);
|
return this.service.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
|
||||||
@SerializeOptions({})
|
|
||||||
async store(
|
|
||||||
@Body()
|
|
||||||
data: CreateTagDto,
|
|
||||||
) {
|
|
||||||
return this.service.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch()
|
|
||||||
@SerializeOptions({})
|
|
||||||
async update(
|
|
||||||
@Body()
|
|
||||||
date: UpdateTagDto,
|
|
||||||
) {
|
|
||||||
return this.service.update(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete()
|
|
||||||
@SerializeOptions({})
|
|
||||||
async delete(@Body() data: DeleteDto) {
|
|
||||||
return this.service.delete(data.ids);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsDefined, IsUUID } from 'class-validator';
|
import { ArrayMinSize, IsArray, IsDefined, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||||
|
|
||||||
@@ -6,5 +6,7 @@ import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator
|
|||||||
export class DeleteDto {
|
export class DeleteDto {
|
||||||
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
||||||
@IsDefined({ each: true, message: 'The ID must be specified' })
|
@IsDefined({ each: true, message: 'The ID must be specified' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
ids: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
@@ -23,6 +24,8 @@ import { SelectTrashMode } from '@/modules/database/constants';
|
|||||||
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
|
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
|
||||||
import { PaginateOptions } from '@/modules/database/types';
|
import { PaginateOptions } from '@/modules/database/types';
|
||||||
|
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
import { CategoryEntity, PostEntity, TagEntity } from '../entities';
|
import { CategoryEntity, PostEntity, TagEntity } from '../entities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,10 +93,23 @@ export class QueryPostDto implements PaginateOptions {
|
|||||||
@IsUUID(undefined, { message: 'The ID format is incorrect' })
|
@IsUUID(undefined, { message: 'The ID format is incorrect' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据文章作者ID查询
|
||||||
|
*/
|
||||||
|
@IsDataExist(UserEntity, {
|
||||||
|
message: '指定的用户不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, { message: '用户ID格式错误' })
|
||||||
|
@IsOptional()
|
||||||
|
author?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DtoValidation({ groups: ['create'] })
|
@DtoValidation({ groups: ['create'] })
|
||||||
export class CreatePostDto {
|
export class CreatePostDto {
|
||||||
|
/**
|
||||||
|
* 文章标题
|
||||||
|
*/
|
||||||
@MaxLength(255, {
|
@MaxLength(255, {
|
||||||
always: true,
|
always: true,
|
||||||
message: 'The maximum length of the article title is $constraint1',
|
message: 'The maximum length of the article title is $constraint1',
|
||||||
@@ -102,10 +118,16 @@ export class CreatePostDto {
|
|||||||
@IsOptional({ groups: ['update'] })
|
@IsOptional({ groups: ['update'] })
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章内容
|
||||||
|
*/
|
||||||
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
|
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
|
||||||
@IsOptional({ groups: ['update'] })
|
@IsOptional({ groups: ['update'] })
|
||||||
body: string;
|
body: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章描述
|
||||||
|
*/
|
||||||
@MaxLength(500, {
|
@MaxLength(500, {
|
||||||
always: true,
|
always: true,
|
||||||
message: 'The maximum length of the article description is $constraint1',
|
message: 'The maximum length of the article description is $constraint1',
|
||||||
@@ -113,12 +135,18 @@ export class CreatePostDto {
|
|||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否发布(发布时间)
|
||||||
|
*/
|
||||||
@Transform(({ value }) => toBoolean(value))
|
@Transform(({ value }) => toBoolean(value))
|
||||||
@IsBoolean({ always: true })
|
@IsBoolean({ always: true })
|
||||||
@ValidateIf((value) => !isNil(value.publish))
|
@ValidateIf((value) => !isNil(value.publish))
|
||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
publish?: boolean;
|
publish?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO关键字
|
||||||
|
*/
|
||||||
@MaxLength(20, {
|
@MaxLength(20, {
|
||||||
always: true,
|
always: true,
|
||||||
each: true,
|
each: true,
|
||||||
@@ -127,12 +155,18 @@ export class CreatePostDto {
|
|||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义排序
|
||||||
|
*/
|
||||||
@Transform(({ value }) => toNumber(value))
|
@Transform(({ value }) => toNumber(value))
|
||||||
@Min(0, { message: 'The sorted value must be greater than 0.', always: true })
|
@Min(0, { message: 'The sorted value must be greater than 0.', always: true })
|
||||||
@IsInt({ always: true })
|
@IsInt({ always: true })
|
||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
customOrder?: number;
|
customOrder?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所属分类ID
|
||||||
|
*/
|
||||||
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
|
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
|
||||||
@IsUUID(undefined, {
|
@IsUUID(undefined, {
|
||||||
always: true,
|
always: true,
|
||||||
@@ -141,6 +175,9 @@ export class CreatePostDto {
|
|||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联标签ID
|
||||||
|
*/
|
||||||
@IsDataExist(TagEntity, {
|
@IsDataExist(TagEntity, {
|
||||||
always: true,
|
always: true,
|
||||||
each: true,
|
each: true,
|
||||||
@@ -153,10 +190,30 @@ export class CreatePostDto {
|
|||||||
})
|
})
|
||||||
@IsOptional({ always: true })
|
@IsOptional({ always: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章作者ID:可用于在管理员发布文章时分配给其它用户,如果不设置,则作者为当前管理员
|
||||||
|
*/
|
||||||
|
@IsDataExist(UserEntity, {
|
||||||
|
always: true,
|
||||||
|
message: '用户不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, {
|
||||||
|
always: true,
|
||||||
|
message: '用户ID格式不正确',
|
||||||
|
})
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
author?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章更新验证
|
||||||
|
*/
|
||||||
@DtoValidation({ groups: ['update'] })
|
@DtoValidation({ groups: ['update'] })
|
||||||
export class UpdatePostDto extends PartialType(CreatePostDto) {
|
export class UpdatePostDto extends PartialType(CreatePostDto) {
|
||||||
|
/**
|
||||||
|
* 待更新ID
|
||||||
|
*/
|
||||||
@IsUUID(undefined, {
|
@IsUUID(undefined, {
|
||||||
groups: ['update'],
|
groups: ['update'],
|
||||||
message: 'The format of the article ID is incorrect.',
|
message: 'The format of the article ID is incorrect.',
|
||||||
@@ -165,3 +222,45 @@ export class UpdatePostDto extends PartialType(CreatePostDto) {
|
|||||||
@IsDataExist(PostEntity, { groups: ['update'], message: 'post id not exist when update' })
|
@IsDataExist(PostEntity, { groups: ['update'], message: 'post id not exist when update' })
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端查询文章列表验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ type: 'query' })
|
||||||
|
export class FrontendQueryPostDto extends OmitType(QueryPostDto, ['isPublished', 'trashed']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端创建文章验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: ['create'] })
|
||||||
|
export class FrontendCreatePostDto extends OmitType(CreatePostDto, ['author', 'customOrder']) {
|
||||||
|
/**
|
||||||
|
* 用户侧排序:文章在用户的文章管理而非后台中,列表的排序规则
|
||||||
|
*/
|
||||||
|
@Transform(({ value }) => toNumber(value))
|
||||||
|
@Min(0, { always: true, message: '排序值必须大于0' })
|
||||||
|
@IsNumber(undefined, { always: true })
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
userOrder?: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户文章更新验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: ['update'] })
|
||||||
|
export class OwnerUpdatePostDto extends OmitType(UpdatePostDto, ['author', 'customOrder']) {
|
||||||
|
/**
|
||||||
|
* 用户侧排序:文章在用户的文章管理而非后台中,列表的排序规则
|
||||||
|
*/
|
||||||
|
@Transform(({ value }) => toNumber(value))
|
||||||
|
@Min(0, { always: true, message: '排序值必须大于0' })
|
||||||
|
@IsNumber(undefined, { always: true })
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
userOrder?: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户查询自己的文章列表验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ type: 'query' })
|
||||||
|
export class OwnerQueryPostDto extends OmitType(QueryPostDto, ['author']) {}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export class PostEntity extends BaseEntity {
|
|||||||
@Column({ comment: '文章描述', nullable: true })
|
@Column({ comment: '文章描述', nullable: true })
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
|
||||||
@Expose()
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
@@ -69,7 +68,7 @@ export class PostEntity extends BaseEntity {
|
|||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => Date)
|
@Type(() => Date)
|
||||||
@DeleteDateColumn({ comment: '删除时间' })
|
@DeleteDateColumn({ comment: '删除时间' })
|
||||||
deleteAt: Date;
|
deletedAt: Date;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
||||||
|
import { PermissionAction, SystemRoles } from '@/modules/rbac/constants';
|
||||||
|
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
|
||||||
|
import { RbacResolver } from '@/modules/rbac/rbac.resolver';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContentRbac implements OnModuleInit {
|
||||||
|
constructor(private ref: ModuleRef) {}
|
||||||
|
onModuleInit() {
|
||||||
|
const resolver = this.ref.get(RbacResolver, { strict: false });
|
||||||
|
resolver.addPermissions([
|
||||||
|
{
|
||||||
|
name: 'post.create',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.CREATE,
|
||||||
|
subject: PostEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'post.owner',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.OWNER,
|
||||||
|
subject: PostEntity,
|
||||||
|
conditions: (user) => ({
|
||||||
|
'author.id': user.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'comment.create',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.CREATE,
|
||||||
|
subject: CommentEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'comment.owner',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.OWNER,
|
||||||
|
subject: CommentEntity,
|
||||||
|
conditions: (user) => ({
|
||||||
|
'author.id': user.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'post.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: PostEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tag.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: TagEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'category.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: CategoryEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'comment.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: CommentEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'permission.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: PermissionEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'role.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: RoleEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user.manage',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: UserEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolver.addRoles([
|
||||||
|
{
|
||||||
|
name: SystemRoles.USER,
|
||||||
|
permissions: [
|
||||||
|
'post.read',
|
||||||
|
'post.create',
|
||||||
|
'post.owner',
|
||||||
|
'comment.create',
|
||||||
|
'comment.owner',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'content-manage',
|
||||||
|
label: '内容管理员',
|
||||||
|
description: '管理内容模块',
|
||||||
|
permissions: ['post.manage', 'category.manage', 'tag.manage', 'comment.manage'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,13 @@ export class PostRepository extends BaseRepository<PostEntity> {
|
|||||||
return this.createQueryBuilder(this.qbName)
|
return this.createQueryBuilder(this.qbName)
|
||||||
.leftJoinAndSelect(`${this.qbName}.category`, 'category')
|
.leftJoinAndSelect(`${this.qbName}.category`, 'category')
|
||||||
.leftJoinAndSelect(`${this.qbName}.tags`, 'tags')
|
.leftJoinAndSelect(`${this.qbName}.tags`, 'tags')
|
||||||
|
.leftJoinAndSelect(`${this.qbName}.author`, 'author')
|
||||||
.addSelect((query) => {
|
.addSelect((query) => {
|
||||||
return query
|
return query
|
||||||
.select('COUNT(c.id)', 'count')
|
.select('COUNT(c.id)', 'count')
|
||||||
.from(CommentEntity, 'c')
|
.from(CommentEntity, 'c')
|
||||||
.where(`c.post.id = ${this.qbName}.id`);
|
.where(`c.post.id = ${this.qbName}.id`);
|
||||||
}, 'commentCount')
|
}, 'commentCount')
|
||||||
.loadRelationCountAndMap(`${this.qbName}.commentCOunt`, `${this.qbName}.comments`);
|
.loadRelationCountAndMap(`${this.qbName}.commentCount`, `${this.qbName}.comments`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { RouteOption, TagOption } from '@/modules/restful/types';
|
||||||
|
|
||||||
|
import * as controllers from './controllers';
|
||||||
|
import * as manageControllers from './controllers/manager';
|
||||||
|
|
||||||
|
export const createContentApi = () => {
|
||||||
|
const routes: Record<'app' | 'manager', RouteOption[]> = {
|
||||||
|
app: [
|
||||||
|
{
|
||||||
|
name: 'app.content',
|
||||||
|
path: 'content',
|
||||||
|
controllers: Object.values(controllers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
{
|
||||||
|
name: 'manage.content',
|
||||||
|
path: 'content',
|
||||||
|
controllers: Object.values(manageControllers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tags: Record<'app' | 'manager', Array<string | TagOption>> = {
|
||||||
|
app: [
|
||||||
|
{ name: '分类查询', description: '查询分类信息' },
|
||||||
|
{ name: '标签查询', description: '查询标签信息' },
|
||||||
|
{
|
||||||
|
name: '文章操作',
|
||||||
|
description: '查询文章以及对自己的文章进行CRUD操作',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '评论操作',
|
||||||
|
description: '查看评论以及对自己的评论进行CRD操作',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
{ name: '分类管理', description: '管理分类信息' },
|
||||||
|
{ name: '标签管理', description: '管理标签信息' },
|
||||||
|
{ name: '文章管理', description: '管理文章信息' },
|
||||||
|
{ name: '评论管理', description: '管理评论信息' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { routes, tags };
|
||||||
|
};
|
||||||
@@ -13,11 +13,14 @@ import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
|||||||
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
||||||
import { BaseService } from '@/modules/database/base/service';
|
import { BaseService } from '@/modules/database/base/service';
|
||||||
import { treePaginate } from '@/modules/database/utils';
|
import { treePaginate } from '@/modules/database/utils';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
||||||
constructor(
|
constructor(
|
||||||
protected repository: CommentRepository,
|
protected repository: CommentRepository,
|
||||||
|
protected userRepository: UserRepository,
|
||||||
protected postRepository: PostRepository,
|
protected postRepository: PostRepository,
|
||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
@@ -50,7 +53,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
|
|||||||
return treePaginate(query, comments);
|
return treePaginate(query, comments);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateCommentDto) {
|
async create(data: CreateCommentDto, author: ClassToPlain<UserEntity>) {
|
||||||
const parent = await this.getParent(undefined, data.parent);
|
const parent = await this.getParent(undefined, data.parent);
|
||||||
if (!isNil(parent) && parent.post.id !== data.post) {
|
if (!isNil(parent) && parent.post.id !== data.post) {
|
||||||
throw new ForbiddenException('Parent comment and child comment must belong same post!');
|
throw new ForbiddenException('Parent comment and child comment must belong same post!');
|
||||||
@@ -59,6 +62,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
|
|||||||
...data,
|
...data,
|
||||||
parent,
|
parent,
|
||||||
post: await this.getPost(data.post),
|
post: await this.getPost(data.post),
|
||||||
|
author: await this.userRepository.findOneByOrFail({ id: author.id }),
|
||||||
});
|
});
|
||||||
return this.repository.findOneOrFail({
|
return this.repository.findOneOrFail({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { isArray, isFunction, omit, pick } from 'lodash';
|
|||||||
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { PostOrder } from '@/modules/content/constants';
|
import { PostOrder } from '@/modules/content/constants';
|
||||||
import { CreatePostDto, QueryPostDto, UpdatePostDto } from '@/modules/content/dtos/post.dto';
|
import {
|
||||||
|
CreatePostDto,
|
||||||
|
FrontendCreatePostDto,
|
||||||
|
QueryPostDto,
|
||||||
|
UpdatePostDto,
|
||||||
|
} from '@/modules/content/dtos/post.dto';
|
||||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||||
import { CategoryRepository } from '@/modules/content/repositories';
|
import { CategoryRepository } from '@/modules/content/repositories';
|
||||||
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
import { PostRepository } from '@/modules/content/repositories/post.repository';
|
||||||
@@ -16,6 +21,10 @@ import { SelectTrashMode } from '@/modules/database/constants';
|
|||||||
import { QueryHook } from '@/modules/database/types';
|
import { QueryHook } from '@/modules/database/types';
|
||||||
import { paginate } from '@/modules/database/utils';
|
import { paginate } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
import { TagRepository } from '../repositories/tag.repository';
|
import { TagRepository } from '../repositories/tag.repository';
|
||||||
|
|
||||||
import { CategoryService } from './category.service';
|
import { CategoryService } from './category.service';
|
||||||
@@ -33,6 +42,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
protected categoryRepository: CategoryRepository,
|
protected categoryRepository: CategoryRepository,
|
||||||
protected categoryService: CategoryService,
|
protected categoryService: CategoryService,
|
||||||
protected tagRepository: TagRepository,
|
protected tagRepository: TagRepository,
|
||||||
|
protected userRepository: UserRepository,
|
||||||
protected searchService?: SearchService,
|
protected searchService?: SearchService,
|
||||||
protected searchType: SearchType = 'mysql',
|
protected searchType: SearchType = 'mysql',
|
||||||
) {
|
) {
|
||||||
@@ -61,11 +71,19 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreatePostDto) {
|
/**
|
||||||
|
* 创建文章
|
||||||
|
* @param data
|
||||||
|
* @param author
|
||||||
|
*/
|
||||||
|
async create(data: CreatePostDto | FrontendCreatePostDto, author: ClassToPlain<UserEntity>) {
|
||||||
let publishedAt: Date | null;
|
let publishedAt: Date | null;
|
||||||
if (!isNil(data.publish)) {
|
if (!isNil(data.publish)) {
|
||||||
publishedAt = data.publish ? new Date() : null;
|
publishedAt = data.publish ? new Date() : null;
|
||||||
}
|
}
|
||||||
|
const authorId = isNil((data as CreatePostDto).author)
|
||||||
|
? author.id
|
||||||
|
: (data as CreatePostDto).author;
|
||||||
const createPostDto = {
|
const createPostDto = {
|
||||||
...omit(data, ['publish']),
|
...omit(data, ['publish']),
|
||||||
category: isNil(data.category)
|
category: isNil(data.category)
|
||||||
@@ -73,6 +91,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
|
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
|
||||||
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
|
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
|
||||||
publishedAt,
|
publishedAt,
|
||||||
|
author: await this.userRepository.findOneByOrFail({ id: authorId }),
|
||||||
};
|
};
|
||||||
const item = await this.repository.save(createPostDto);
|
const item = await this.repository.save(createPostDto);
|
||||||
const result = await this.detail(item.id);
|
const result = await this.detail(item.id);
|
||||||
@@ -82,12 +101,20 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
async update(data: UpdatePostDto) {
|
async update(data: UpdatePostDto) {
|
||||||
let publishedAt: Date | null;
|
let publishedAt: Date | null;
|
||||||
if (!isNil(data.publish)) {
|
if (!isNil(data.publish)) {
|
||||||
publishedAt = data.publish ? new Date() : null;
|
publishedAt = data.publish ? new Date() : null;
|
||||||
}
|
}
|
||||||
const post = await this.detail(data.id);
|
const post = await this.detail(data.id);
|
||||||
|
if (!isNil(data.author)) {
|
||||||
|
post.author = await this.userRepository.findOneByOrFail({ id: data.author });
|
||||||
|
await this.repository.save(post);
|
||||||
|
}
|
||||||
if (data.category !== undefined) {
|
if (data.category !== undefined) {
|
||||||
post.category = isNil(data.category)
|
post.category = isNil(data.category)
|
||||||
? null
|
? null
|
||||||
@@ -102,7 +129,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
.addAndRemove(data.tags, post.tags ?? []);
|
.addAndRemove(data.tags, post.tags ?? []);
|
||||||
}
|
}
|
||||||
await this.repository.update(data.id, {
|
await this.repository.update(data.id, {
|
||||||
...omit(data, ['id', 'publish', 'tags', 'category']),
|
...omit(data, ['id', 'publish', 'tags', 'category', 'author']),
|
||||||
publishedAt,
|
publishedAt,
|
||||||
});
|
});
|
||||||
const result = await this.detail(data.id);
|
const result = await this.detail(data.id);
|
||||||
@@ -120,8 +147,8 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
.getMany();
|
.getMany();
|
||||||
let result: PostEntity[];
|
let result: PostEntity[];
|
||||||
if (trash) {
|
if (trash) {
|
||||||
const directs = items.filter((item) => !isNil(item.deleteAt));
|
const directs = items.filter((item) => !isNil(item.deletedAt));
|
||||||
const softs = items.filter((item) => isNil(item.deleteAt));
|
const softs = items.filter((item) => isNil(item.deletedAt));
|
||||||
result = [
|
result = [
|
||||||
...(await this.repository.remove(directs)),
|
...(await this.repository.remove(directs)),
|
||||||
...(await this.repository.softRemove(softs)),
|
...(await this.repository.softRemove(softs)),
|
||||||
@@ -145,7 +172,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
.where('post.id IN (:...ids)', { ids })
|
.where('post.id IN (:...ids)', { ids })
|
||||||
.withDeleted()
|
.withDeleted()
|
||||||
.getMany();
|
.getMany();
|
||||||
const trashes = items.filter((item) => !isNil(item.deleteAt));
|
const trashes = items.filter((item) => !isNil(item.deletedAt));
|
||||||
await this.searchService.update(trashes);
|
await this.searchService.update(trashes);
|
||||||
const trashedIds = trashes.map((item) => item.id);
|
const trashedIds = trashes.map((item) => item.id);
|
||||||
if (trashedIds.length < 1) {
|
if (trashedIds.length < 1) {
|
||||||
@@ -163,7 +190,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
options: FindParams,
|
options: FindParams,
|
||||||
callback?: QueryHook<PostEntity>,
|
callback?: QueryHook<PostEntity>,
|
||||||
) {
|
) {
|
||||||
const { orderBy, isPublished, category, tag, trashed, search } = options;
|
const { orderBy, isPublished, category, tag, trashed, search, author } = options;
|
||||||
if (typeof isPublished === 'boolean') {
|
if (typeof isPublished === 'boolean') {
|
||||||
isPublished
|
isPublished
|
||||||
? qb.where({ publishedAt: Not(IsNull()) })
|
? qb.where({ publishedAt: Not(IsNull()) })
|
||||||
@@ -185,6 +212,9 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
if (tag) {
|
if (tag) {
|
||||||
qb.where('tags.id = :id', { id: tag });
|
qb.where('tags.id = :id', { id: tag });
|
||||||
}
|
}
|
||||||
|
if (author) {
|
||||||
|
qb.where('author.id = :id', { id: author });
|
||||||
|
}
|
||||||
if (callback) {
|
if (callback) {
|
||||||
return callback(qb);
|
return callback(qb);
|
||||||
}
|
}
|
||||||
@@ -225,6 +255,6 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
|||||||
const tree = await this.categoryRepository.findDescendantsTree(root);
|
const tree = await this.categoryRepository.findDescendantsTree(root);
|
||||||
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
|
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
|
||||||
const ids = [tree.id, ...flatDes.map((item) => item.id)];
|
const ids = [tree.id, ...flatDes.map((item) => item.id)];
|
||||||
return qb.where('categoryRepository.id IN (:...ids)', { ids });
|
return qb.where('category.id IN (:...ids)', { ids });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class SearchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async search(text: string, param: SearchOption = {}): Promise<any> {
|
async search(text: string, param: SearchOption = {}): Promise<any> {
|
||||||
const option = { page: 1, limit: 10, trashed: SelectTrashMode.ONLY, ...param };
|
const option = { page: 1, limit: 10, trashed: SelectTrashMode.NONE, ...param };
|
||||||
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
const limit = isNil(option.limit) || option.limit < 1 ? 1 : option.limit;
|
||||||
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
const page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
||||||
let filter = ['deletedAt IS NULL'];
|
let filter = ['deletedAt IS NULL'];
|
||||||
@@ -64,7 +64,7 @@ export class SearchService implements OnModuleInit {
|
|||||||
.index(this.index)
|
.index(this.index)
|
||||||
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
|
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
|
||||||
return {
|
return {
|
||||||
item: result.hits,
|
items: result.hits,
|
||||||
currentPage: result.page,
|
currentPage: result.page,
|
||||||
perPage: result.hitsPerPage,
|
perPage: result.hitsPerPage,
|
||||||
totalItems: result.estimatedTotalHits,
|
totalItems: result.estimatedTotalHits,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function getSearchItem(
|
|||||||
'body',
|
'body',
|
||||||
'summary',
|
'summary',
|
||||||
'commentCount',
|
'commentCount',
|
||||||
'deleteAt',
|
'deletedAt',
|
||||||
'publishedAt',
|
'publishedAt',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedAt',
|
'updatedAt',
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isArray, isNil, omit } from 'lodash';
|
||||||
|
|
||||||
|
import { RedisService, SmsService, SmtpService } from '@/modules/core/services';
|
||||||
|
import type {
|
||||||
|
QueueOptions,
|
||||||
|
RedisOption,
|
||||||
|
RedisOptions,
|
||||||
|
SmsOptions,
|
||||||
|
SmtpOptions,
|
||||||
|
} from '@/modules/core/types';
|
||||||
|
|
||||||
|
import { createQueueOptions, createRedisOptions } from '@/options';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
import { Configure } from '../config/configure';
|
||||||
|
|
||||||
@@ -6,11 +20,56 @@ import { Configure } from '../config/configure';
|
|||||||
export class CoreModule {
|
export class CoreModule {
|
||||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||||
await configure.store('app.name');
|
await configure.store('app.name');
|
||||||
|
const providers: ModuleMetadata['providers'] = [];
|
||||||
|
const exports: ModuleMetadata['exports'] = [];
|
||||||
|
let imports: ModuleMetadata['imports'] = [];
|
||||||
|
const redis: RedisOption[] | undefined = createRedisOptions(
|
||||||
|
await configure.get<RedisOptions>('redis'),
|
||||||
|
);
|
||||||
|
if (!isNil(redis)) {
|
||||||
|
providers.push({
|
||||||
|
provide: RedisService,
|
||||||
|
useFactory: () => {
|
||||||
|
const service = new RedisService(redis);
|
||||||
|
service.createClients();
|
||||||
|
return service;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
exports.push(RedisService);
|
||||||
|
|
||||||
|
const queues = createQueueOptions(await configure.get<QueueOptions>('queue'), redis);
|
||||||
|
if (!isNil(queues)) {
|
||||||
|
if (isArray(queues)) {
|
||||||
|
imports = queues.map((v) => BullModule.forRoot(v.name, omit(v, 'name')));
|
||||||
|
} else {
|
||||||
|
imports.push(BullModule.forRoot(queues));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sms = await configure.get<SmsOptions>('sms');
|
||||||
|
if (!isNil(sms)) {
|
||||||
|
providers.push({
|
||||||
|
provide: SmsService,
|
||||||
|
useFactory: () => new SmsService(sms),
|
||||||
|
});
|
||||||
|
exports.push(SmsService);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smtp = await configure.get<SmtpOptions>('smtp');
|
||||||
|
if (!isNil(smtp)) {
|
||||||
|
providers.push({
|
||||||
|
provide: SmtpService,
|
||||||
|
useFactory: () => new SmtpService(smtp),
|
||||||
|
});
|
||||||
|
exports.push(SmtpService);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
module: CoreModule,
|
module: CoreModule,
|
||||||
global: true,
|
global: true,
|
||||||
providers: [],
|
providers,
|
||||||
exports: [],
|
exports,
|
||||||
|
imports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Paramtype, SetMetadata } from '@nestjs/common';
|
import { Paramtype, SetMetadata } from '@nestjs/common';
|
||||||
import { ClassTransformOptions } from 'class-transformer';
|
import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
|
||||||
import { ValidationOptions } from 'class-validator';
|
import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
|
||||||
|
|
||||||
import { DTO_VALIDATION_OPTIONS } from '../contants';
|
import { DTO_VALIDATION_OPTIONS } from '../contants';
|
||||||
|
|
||||||
export const DtoValidation = (
|
export const DtoValidation = (
|
||||||
options?: ValidationOptions & { transformOptions?: ClassTransformOptions } & {
|
options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
|
||||||
type?: Paramtype;
|
type?: Paramtype;
|
||||||
},
|
},
|
||||||
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});
|
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
const response = ctx.getResponse<FastifyReply>();
|
||||||
|
|
||||||
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
let message = 'Internal server error';
|
||||||
|
let details: any = null;
|
||||||
|
|
||||||
|
// 记录完整的错误信息
|
||||||
|
this.logError(exception, request);
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
message =
|
||||||
|
typeof exceptionResponse === 'string'
|
||||||
|
? exceptionResponse
|
||||||
|
: (exceptionResponse as any).message || exception.message;
|
||||||
|
} else if (exception instanceof QueryFailedError) {
|
||||||
|
// 专门处理 TypeORM 查询错误
|
||||||
|
status = HttpStatus.BAD_REQUEST;
|
||||||
|
message = 'Database query failed';
|
||||||
|
details = {
|
||||||
|
query: exception.query,
|
||||||
|
parameters: exception.parameters,
|
||||||
|
driverError: exception.driverError?.message,
|
||||||
|
sqlMessage: (exception as any).sqlMessage,
|
||||||
|
code: (exception as any).code,
|
||||||
|
errno: (exception as any).errno,
|
||||||
|
};
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
message = exception.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorResponse = {
|
||||||
|
statusCode: status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
method: request.method,
|
||||||
|
message,
|
||||||
|
...(details && { details }),
|
||||||
|
};
|
||||||
|
|
||||||
|
response.status(status).send(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private logError(exception: unknown, request: FastifyRequest) {
|
||||||
|
const errorInfo = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
query: request.query,
|
||||||
|
params: request.params,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exception instanceof QueryFailedError) {
|
||||||
|
this.logger.error(`Database Query Failed: ${exception.message}`, {
|
||||||
|
...errorInfo,
|
||||||
|
query: exception.query,
|
||||||
|
parameters: exception.parameters,
|
||||||
|
driverError: exception.driverError,
|
||||||
|
stack: exception.stack,
|
||||||
|
sqlMessage: (exception as any).sqlMessage,
|
||||||
|
code: (exception as any).code,
|
||||||
|
errno: (exception as any).errno,
|
||||||
|
});
|
||||||
|
} else if (exception instanceof Error) {
|
||||||
|
this.logger.error(`Unhandled Exception: ${exception.message}`, {
|
||||||
|
...errorInfo,
|
||||||
|
stack: exception.stack,
|
||||||
|
name: exception.name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.error('Unknown Exception', {
|
||||||
|
...errorInfo,
|
||||||
|
exception,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './redis.service';
|
||||||
|
export * from './sms.service';
|
||||||
|
export * from './smtp.service';
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { RedisOption } from '@/modules/core/types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService {
|
||||||
|
protected options: RedisOption[];
|
||||||
|
|
||||||
|
protected clients: Record<string, Redis> = {};
|
||||||
|
|
||||||
|
constructor(protected _options: RedisOption[]) {
|
||||||
|
this.options = _options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createClients() {
|
||||||
|
this.options.map(async (option) => {
|
||||||
|
this.clients[option.name] = new Redis(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(name?: string): Redis {
|
||||||
|
const key = isNil(name) ? 'default' : name;
|
||||||
|
if (this.clients[key]) {
|
||||||
|
return this.clients[key];
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown client ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getClients() {
|
||||||
|
return this.clients;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
|
||||||
|
|
||||||
|
import { deepMerge } from '@/modules/core/helpers';
|
||||||
|
import { SmsOptions, SmsSendParams } from '@/modules/core/types';
|
||||||
|
|
||||||
|
const SmsClient = tencentcloud.sms.v20210111.Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 腾讯云短信驱动
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SmsService {
|
||||||
|
/**
|
||||||
|
* 初始化配置
|
||||||
|
* @param options 短信发送选项
|
||||||
|
*/
|
||||||
|
constructor(protected options: SmsOptions) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建短信发送驱动实例
|
||||||
|
* @param options 驱动选项
|
||||||
|
*/
|
||||||
|
protected makeClient(options: SmsOptions) {
|
||||||
|
const { secretId, secretKey, region, endpoint } = options;
|
||||||
|
return new SmsClient({
|
||||||
|
credential: { secretId, secretKey },
|
||||||
|
region,
|
||||||
|
profile: {
|
||||||
|
httpProfile: { endpoint: endpoint ?? 'sms.tencentcloudapi.com' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义通用发送参数为腾讯云短信服务发送参数
|
||||||
|
* @param params 发送参数
|
||||||
|
* @param options 驱动选项
|
||||||
|
*/
|
||||||
|
protected transSendParams(params: SmsSendParams, options: SmsOptions) {
|
||||||
|
const { numbers, template, vars, appid, sign, ...others } = params;
|
||||||
|
let paramSet: RecordAny = {};
|
||||||
|
if (vars) {
|
||||||
|
paramSet = Object.fromEntries(
|
||||||
|
Object.entries(vars).map(([key, value]) => [key, value.toString()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
PhoneNumberSet: numbers.map((n) => {
|
||||||
|
const phone: string[] = n.split('.');
|
||||||
|
return `${phone[0]}${phone[1]}`;
|
||||||
|
}),
|
||||||
|
TemplateId: template,
|
||||||
|
SmsSdkAppId: appid ?? options.appid,
|
||||||
|
SignName: sign ?? options.sign,
|
||||||
|
TemplateParamSet: Object.values(paramSet),
|
||||||
|
...(others ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并配置并发送短信
|
||||||
|
* @param params 短信发送参数
|
||||||
|
* @param options 自定义驱动选项(可用于临时覆盖默认选项)
|
||||||
|
*/
|
||||||
|
async send<T>(params: SmsSendParams & T, options?: SmsSendParams) {
|
||||||
|
const newOptions = deepMerge(this.options, options ?? {}) as SmsOptions;
|
||||||
|
const client = this.makeClient(newOptions);
|
||||||
|
return client.SendSms(this.transSendParams(params, newOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import Email from 'email-templates';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import mailer from 'nodemailer';
|
||||||
|
import Mail from 'nodemailer/lib/mailer';
|
||||||
|
import SMTPConnection from 'nodemailer/lib/smtp-connection';
|
||||||
|
|
||||||
|
import { deepMerge } from '@/modules/core/helpers';
|
||||||
|
import { SmtpOptions, SmtpSendParams } from '@/modules/core/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP邮件发送驱动
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SmtpService {
|
||||||
|
/**
|
||||||
|
* 初始化配置
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
constructor(protected readonly options: SmtpOptions) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并配置并发送邮件
|
||||||
|
* @param params
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async send<T>(params: SmtpSendParams & T, options?: SmtpOptions) {
|
||||||
|
const newOptions = deepMerge(this.options, options ?? {}) as SmtpOptions;
|
||||||
|
const client = this.makeClient(newOptions);
|
||||||
|
return this.makeSend(client, params, newOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建NodeMailer客户端
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
protected makeClient(options: SmtpOptions) {
|
||||||
|
const { host, secure, user, password, port } = options;
|
||||||
|
const clientOptions: SMTPConnection.Options = {
|
||||||
|
host,
|
||||||
|
secure: secure ?? false,
|
||||||
|
auth: {
|
||||||
|
user,
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (!clientOptions.secure) {
|
||||||
|
clientOptions.port = port ?? 25;
|
||||||
|
}
|
||||||
|
return mailer.createTransport(clientOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转义通用发送参数为NodeMailer发送参数
|
||||||
|
* @param client
|
||||||
|
* @param params
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
protected async makeSend(client: Mail, params: SmtpSendParams, options: SmtpOptions) {
|
||||||
|
const tplPath = path.resolve(options.resource, params.name ?? 'custom');
|
||||||
|
const textOnly = !params.html && params.text;
|
||||||
|
const noHtmlToText = params.html && params.text;
|
||||||
|
const configd: Email.EmailConfig = {
|
||||||
|
preview: params.preview ?? false,
|
||||||
|
send: !params.preview,
|
||||||
|
message: { from: params.from ?? options.from ?? options.user },
|
||||||
|
transport: client,
|
||||||
|
subjectPrefix: params.subjectPrefix,
|
||||||
|
textOnly,
|
||||||
|
juiceResources: {
|
||||||
|
preserveImportant: true,
|
||||||
|
webResources: {
|
||||||
|
relativeTo: tplPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (noHtmlToText) {
|
||||||
|
configd.htmlToText = false;
|
||||||
|
}
|
||||||
|
const email = new Email(configd);
|
||||||
|
const message = {
|
||||||
|
...pick(params, ['from', 'to', 'reply', 'attachments', 'subject']),
|
||||||
|
locals: params.vars,
|
||||||
|
};
|
||||||
|
return email.send({
|
||||||
|
template: tplPath,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
|
|||||||
import { IAuthGuard } from '@nestjs/passport';
|
import { IAuthGuard } from '@nestjs/passport';
|
||||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
|
||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { QueueOptions as BullMQOptions } from 'bullmq';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import Email from 'email-templates';
|
||||||
|
import { RedisOptions as IORedisOptions } from 'ioredis';
|
||||||
|
import { Attachment } from 'nodemailer/lib/mailer';
|
||||||
import { Ora } from 'ora';
|
import { Ora } from 'ora';
|
||||||
import { StartOptions } from 'pm2';
|
import { StartOptions } from 'pm2';
|
||||||
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
@@ -142,3 +147,112 @@ export interface DynamicRelation {
|
|||||||
| ReturnType<typeof ManyToMany>;
|
| ReturnType<typeof ManyToMany>;
|
||||||
column: string;
|
column: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 嵌套对象
|
||||||
|
*/
|
||||||
|
export type NestedRecord = Record<string, RecordAny>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* core模块参数选项
|
||||||
|
*/
|
||||||
|
export interface CoreOptions {
|
||||||
|
database?: () => TypeOrmModuleOptions;
|
||||||
|
sms?: () => SmsOptions;
|
||||||
|
smtp?: () => SmtpOptions;
|
||||||
|
redis?: () => RedisOptions;
|
||||||
|
queue?: () => QueueOptions;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 腾讯云短信驱动配置
|
||||||
|
*/
|
||||||
|
export type SmsOptions<T extends NestedRecord = RecordNever> = {
|
||||||
|
secretId: string;
|
||||||
|
secretKey: string;
|
||||||
|
sign: string;
|
||||||
|
appid: string;
|
||||||
|
region: string;
|
||||||
|
endpoint?: string;
|
||||||
|
} & T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送接口参数
|
||||||
|
*/
|
||||||
|
export interface SmsSendParams {
|
||||||
|
appid?: string;
|
||||||
|
numbers: string[];
|
||||||
|
template: string;
|
||||||
|
sign?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
vars?: Record<string, any>;
|
||||||
|
ExtendCode?: string;
|
||||||
|
SessionContext?: string;
|
||||||
|
SenderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP邮件发送配置
|
||||||
|
*/
|
||||||
|
export type SmtpOptions<T extends NestedRecord = RecordNever> = {
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
// Email模板总路径
|
||||||
|
resource: string;
|
||||||
|
from?: string;
|
||||||
|
port?: number;
|
||||||
|
secure?: boolean;
|
||||||
|
} & T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公共发送接口配置
|
||||||
|
*/
|
||||||
|
export interface SmtpSendParams {
|
||||||
|
// 模板名称
|
||||||
|
name?: string;
|
||||||
|
// 发信地址
|
||||||
|
from?: string;
|
||||||
|
// 主题
|
||||||
|
subject?: string;
|
||||||
|
// 目标地址
|
||||||
|
to: string | string[];
|
||||||
|
// 回信地址
|
||||||
|
reply?: string;
|
||||||
|
// 是否加载html模板
|
||||||
|
html?: boolean;
|
||||||
|
// 是否加载text模板
|
||||||
|
text?: boolean;
|
||||||
|
// 模板变量
|
||||||
|
vars?: Record<string, any>;
|
||||||
|
// 是否预览
|
||||||
|
preview?: boolean | Email.PreviewEmailOpts;
|
||||||
|
// 主题前缀
|
||||||
|
subjectPrefix?: string;
|
||||||
|
// 附件
|
||||||
|
attachments?: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis连接配置
|
||||||
|
*/
|
||||||
|
export type RedisOption = Omit<IORedisOptions, 'name'> & { name: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis配置
|
||||||
|
*/
|
||||||
|
export type RedisOptions = IORedisOptions | Array<RedisOption>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 队列项配置
|
||||||
|
*/
|
||||||
|
export type QueueOption = Omit<BullMQOptions, 'connection'> & { redis?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 队列配置
|
||||||
|
*/
|
||||||
|
export type QueueOptions = QueueOption | Array<{ name: string } & QueueOption>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BullMQ模块注册配置
|
||||||
|
*/
|
||||||
|
export type BullOptions = BullMQOptions | Array<{ name: string } & BullMQOptions>;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export abstract class BaseService<
|
|||||||
|
|
||||||
async detail(id: string, callback?: QueryHook<T>) {
|
async detail(id: string, callback?: QueryHook<T>) {
|
||||||
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
|
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
|
||||||
const item = qb.getOne();
|
const item = await qb.getOne();
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`);
|
throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
UpdateEvent,
|
UpdateEvent,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { LoadEvent } from 'typeorm/subscriber/event/LoadEvent';
|
||||||
|
|
||||||
import { Configure } from '@/modules/config/configure';
|
import { Configure } from '@/modules/config/configure';
|
||||||
|
|
||||||
import { app } from '@/modules/core/helpers/app';
|
import { app } from '@/modules/core/helpers/app';
|
||||||
@@ -72,7 +74,7 @@ export abstract class BaseSubscriber<T extends ObjectLiteral>
|
|||||||
return this.entity;
|
return this.entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
async afterLoad(entity: any) {
|
async afterLoad(entity: any, event?: LoadEvent<T>) {
|
||||||
if ('parent' in entity && isNil(entity.depth)) {
|
if ('parent' in entity && isNil(entity.depth)) {
|
||||||
entity.depth = 0;
|
entity.depth = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class UniqueConstraint implements ValidatorConstraintInterface {
|
|||||||
constructor(private dataSource: DataSource) {}
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
async validate(value: any, validationArguments?: ValidationArguments): Promise<boolean> {
|
async validate(value: any, validationArguments?: ValidationArguments): Promise<boolean> {
|
||||||
|
if (isNil(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const config: Omit<Condition, 'entity'> = { property: validationArguments.property };
|
const config: Omit<Condition, 'entity'> = { property: validationArguments.property };
|
||||||
const condition = ('entity' in validationArguments.constraints[0]
|
const condition = ('entity' in validationArguments.constraints[0]
|
||||||
? merge(config, validationArguments.constraints[0])
|
? merge(config, validationArguments.constraints[0])
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const ContentFactory = defineFactory(
|
|||||||
post.publishedAt = (await getTime(configure)).toDate();
|
post.publishedAt = (await getTime(configure)).toDate();
|
||||||
}
|
}
|
||||||
if (Math.random() > 0.5) {
|
if (Math.random() > 0.5) {
|
||||||
post.deleteAt = (await getTime(configure)).toDate();
|
post.deletedAt = (await getTime(configure)).toDate();
|
||||||
}
|
}
|
||||||
if (category) {
|
if (category) {
|
||||||
post.category = category;
|
post.category = category;
|
||||||
|
|||||||
@@ -6,3 +6,12 @@ export enum SystemRoles {
|
|||||||
export const SYSTEM_PERMISSION = 'system-manage';
|
export const SYSTEM_PERMISSION = 'system-manage';
|
||||||
|
|
||||||
export const PERMISSION_CHECKERS = 'permission_checkers';
|
export const PERMISSION_CHECKERS = 'permission_checkers';
|
||||||
|
|
||||||
|
export enum PermissionAction {
|
||||||
|
CREATE = 'create',
|
||||||
|
READ = 'read',
|
||||||
|
UPDATE = 'update',
|
||||||
|
DELETE = 'delete',
|
||||||
|
MANAGE = 'manage',
|
||||||
|
OWNER = 'owner',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './role.controller';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './role.controller';
|
||||||
|
export * from './permission.controller';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||||
|
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||||
|
import { PermissionService } from '@/modules/rbac/services';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, PermissionEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('权限管理')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Depends(RbacModule)
|
||||||
|
@Controller('permissions')
|
||||||
|
export class PermissionController {
|
||||||
|
constructor(private service: PermissionService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页列表查询
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['permission-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async list(@Query() options: PaginateWithTrashedDto) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页列表查询
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@SerializeOptions({ groups: ['permission-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Body, Controller, Delete, Patch, Post, SerializeOptions } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { RoleEntity } from '@/modules/rbac/entities';
|
||||||
|
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||||
|
import { RoleService } from '@/modules/rbac/services';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { Permission } from '../../decorators/permission.decorator';
|
||||||
|
import { CreateRoleDto, UpdateRoleDto } from '../../dtos/role.dtos';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, RoleEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('角色管理')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Depends(RbacModule)
|
||||||
|
@Controller('roles')
|
||||||
|
export class RoleController {
|
||||||
|
constructor(private service: RoleService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增角色
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@SerializeOptions({ groups: ['role-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async store(
|
||||||
|
@Body()
|
||||||
|
data: CreateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新角色
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
@SerializeOptions({ groups: ['role-detail'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async update(
|
||||||
|
@Body()
|
||||||
|
data: UpdateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除角色
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@SerializeOptions({ groups: ['role-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async delete(
|
||||||
|
@Body()
|
||||||
|
data: DeleteWithTrashDto,
|
||||||
|
) {
|
||||||
|
const { ids, trash } = data;
|
||||||
|
return this.service.delete(ids, trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量恢复角色
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch('restore')
|
||||||
|
@SerializeOptions({ groups: ['role-list'] })
|
||||||
|
@Permission(permission)
|
||||||
|
async restore(
|
||||||
|
@Body()
|
||||||
|
data: RestoreDto,
|
||||||
|
) {
|
||||||
|
const { ids } = data;
|
||||||
|
return this.service.restore(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||||
|
import { RoleService } from '@/modules/rbac/services';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
|
||||||
|
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||||
|
|
||||||
|
@ApiTags('角色查询')
|
||||||
|
@Depends(RbacModule)
|
||||||
|
@Controller('roles')
|
||||||
|
export class RoleController {
|
||||||
|
constructor(private service: RoleService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色列表查询
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@SerializeOptions({ groups: ['role-list'] })
|
||||||
|
@Guest()
|
||||||
|
async list(@Query() options: PaginateWithTrashedDto) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色详解查询
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@SerializeOptions({ groups: ['role-detail'] })
|
||||||
|
@Guest()
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@ import { SetMetadata } from '@nestjs/common';
|
|||||||
import { PERMISSION_CHECKERS } from '../constants';
|
import { PERMISSION_CHECKERS } from '../constants';
|
||||||
import { PermissionChecker } from '../types';
|
import { PermissionChecker } from '../types';
|
||||||
|
|
||||||
export const Permision = (...checkers: PermissionChecker[]) =>
|
export const Permission = (...checkers: PermissionChecker[]) =>
|
||||||
SetMetadata(PERMISSION_CHECKERS, checkers);
|
SetMetadata(PERMISSION_CHECKERS, checkers);
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './permission.entity';
|
||||||
|
export * from './role.entity';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AbilityTuple, MongoQuery, RawRuleFrom } from '@casl/ability';
|
import { AbilityTuple, MongoQuery, RawRuleFrom } from '@casl/ability';
|
||||||
import { Exclude, Expose } from 'class-transformer';
|
import { Exclude, Expose } from 'class-transformer';
|
||||||
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, Relation } from 'typeorm';
|
import type { Relation } from 'typeorm';
|
||||||
|
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
import { UserEntity } from '@/modules/user/entities';
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export class PermissionEntity<
|
|||||||
* 权限角色
|
* 权限角色
|
||||||
*/
|
*/
|
||||||
@Expose({ groups: ['permission-list', 'permission-detail'] })
|
@Expose({ groups: ['permission-list', 'permission-detail'] })
|
||||||
@ManyToMany(() => RoleEntity, (role) => role.permissions, { cascade: true })
|
@ManyToMany(() => RoleEntity, (role) => role.permissions)
|
||||||
@JoinTable()
|
@JoinTable()
|
||||||
roles: Relation<RoleEntity>[];
|
roles: Relation<RoleEntity>[];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Exclude, Expose, Type } from 'class-transformer';
|
import { Exclude, Expose, Type } from 'class-transformer';
|
||||||
|
import type { Relation } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
BaseEntity,
|
BaseEntity,
|
||||||
Column,
|
Column,
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
JoinTable,
|
JoinTable,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Relation,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
import { UserEntity } from '@/modules/user/entities';
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
@@ -30,18 +30,21 @@ export class RoleEntity extends BaseEntity {
|
|||||||
/**
|
/**
|
||||||
* 角色名称
|
* 角色名称
|
||||||
*/
|
*/
|
||||||
|
@Expose()
|
||||||
@Column({ comment: '角色名称' })
|
@Column({ comment: '角色名称' })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示名称
|
* 显示名称
|
||||||
*/
|
*/
|
||||||
|
@Expose()
|
||||||
@Column({ comment: '显示名称', nullable: true })
|
@Column({ comment: '显示名称', nullable: true })
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 角色描述
|
* 角色描述
|
||||||
*/
|
*/
|
||||||
|
@Expose({ groups: ['role-detail'] })
|
||||||
@Column({ comment: '角色描述', nullable: true, type: 'text' })
|
@Column({ comment: '角色描述', nullable: true, type: 'text' })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import { createMongoAbility } from '@casl/ability';
|
import { createMongoAbility } from '@casl/ability';
|
||||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||||
|
|
||||||
import { isNil } from 'lodash';
|
import { isNil } from 'lodash';
|
||||||
|
|
||||||
|
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||||
import { JwtAuthGuard } from '@/modules/user/guards';
|
import { JwtAuthGuard } from '@/modules/user/guards';
|
||||||
|
|
||||||
import { UserRepository } from '@/modules/user/repositories';
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
import { TokenService } from '@/modules/user/services';
|
import { TokenService } from '@/modules/user/services';
|
||||||
|
|
||||||
import { PERMISSION_CHECKERS } from '../constants';
|
import { PERMISSION_CHECKERS } from '../constants';
|
||||||
import { PermissionEntity } from '../entities/permission.entity';
|
|
||||||
import { RbacResolver } from '../rbac.resolver';
|
import { RbacResolver } from '../rbac.resolver';
|
||||||
|
|
||||||
import { CheckerParams, PermissionChecker } from '../types';
|
import { CheckerParams, PermissionChecker } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
export class RbacGuard extends JwtAuthGuard {
|
export class RbacGuard extends JwtAuthGuard {
|
||||||
constructor(
|
constructor(
|
||||||
protected reflector: Reflector,
|
protected reflector: Reflector,
|
||||||
protected resolver: RbacResolver,
|
protected resolver: RbacResolver,
|
||||||
protected tokenService: TokenService,
|
protected tokenService: TokenService,
|
||||||
protected userRepository: UserRepository,
|
protected userRepository: UserRepository,
|
||||||
protected modeleRef: ModuleRef,
|
protected moduleRef: ModuleRef,
|
||||||
) {
|
) {
|
||||||
super(reflector, tokenService);
|
super(reflector, tokenService);
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,7 @@ export class RbacGuard extends JwtAuthGuard {
|
|||||||
resolver: this.resolver,
|
resolver: this.resolver,
|
||||||
repository: this.userRepository,
|
repository: this.userRepository,
|
||||||
checkers,
|
checkers,
|
||||||
moduleRef: this.modeleRef,
|
moduleRef: this.moduleRef,
|
||||||
request: context.switchToHttp().getRequest(),
|
request: context.switchToHttp().getRequest(),
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { DynamicModule, forwardRef, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { DatabaseModule } from '@/modules/database/database.module';
|
||||||
|
|
||||||
|
import { RbacGuard } from '@/modules/rbac/guards/rbac.guard';
|
||||||
|
|
||||||
|
import { RbacResolver } from '@/modules/rbac/rbac.resolver';
|
||||||
|
|
||||||
|
import { Configure } from '../config/configure';
|
||||||
|
import { addEntities, addSubscribers } from '../database/utils';
|
||||||
|
import { UserModule } from '../user/user.module';
|
||||||
|
|
||||||
|
import * as entities from './entities';
|
||||||
|
import * as repositories from './repositories';
|
||||||
|
import * as services from './services';
|
||||||
|
import * as subscribers from './subscribers';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class RbacModule {
|
||||||
|
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||||
|
return {
|
||||||
|
module: RbacModule,
|
||||||
|
imports: [
|
||||||
|
forwardRef(() => UserModule),
|
||||||
|
await addEntities(configure, Object.values(entities)),
|
||||||
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
...Object.values(services),
|
||||||
|
...(await addSubscribers(configure, Object.values(subscribers))),
|
||||||
|
RbacGuard,
|
||||||
|
{
|
||||||
|
provide: RbacResolver,
|
||||||
|
useFactory: async (dataSource: DataSource) => {
|
||||||
|
const resolver = new RbacResolver(dataSource, configure);
|
||||||
|
resolver.setOptions({});
|
||||||
|
return resolver;
|
||||||
|
},
|
||||||
|
inject: [getDataSourceToken()],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
RbacResolver,
|
||||||
|
...Object.values(services),
|
||||||
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { InternalServerErrorException, OnApplicationBootstrap } from '@nestjs/co
|
|||||||
import { isArray, isNil, omit } from 'lodash';
|
import { isArray, isNil, omit } from 'lodash';
|
||||||
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
||||||
|
|
||||||
|
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||||
|
|
||||||
import { Configure } from '../config/configure';
|
import { Configure } from '../config/configure';
|
||||||
|
|
||||||
import { deepMerge } from '../core/helpers';
|
import { deepMerge } from '../core/helpers';
|
||||||
@@ -11,7 +13,6 @@ import { deepMerge } from '../core/helpers';
|
|||||||
import { UserEntity } from '../user/entities';
|
import { UserEntity } from '../user/entities';
|
||||||
|
|
||||||
import { SYSTEM_PERMISSION, SystemRoles } from './constants';
|
import { SYSTEM_PERMISSION, SystemRoles } from './constants';
|
||||||
import { PermissionEntity } from './entities/permission.entity';
|
|
||||||
import { RoleEntity } from './entities/role.entity';
|
import { RoleEntity } from './entities/role.entity';
|
||||||
import { PermissionType, Role } from './types';
|
import { PermissionType, Role } from './types';
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends Mongo
|
|||||||
if (!this.setuped) {
|
if (!this.setuped) {
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.setuped = true;
|
this.setuped = true;
|
||||||
|
console.log(this.options);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -203,9 +205,12 @@ export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends Mongo
|
|||||||
|
|
||||||
// 同步普通角色
|
// 同步普通角色
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
const rolePermissions = await manager.findBy(PermissionEntity, {
|
const rolePermissions =
|
||||||
name: In(this.roles.find(({ name }) => name === role.name).permissions),
|
isNil(role.permissions) || role.permissions.length <= 0
|
||||||
});
|
? []
|
||||||
|
: await manager.findBy(PermissionEntity, {
|
||||||
|
name: In(role.permissions.map(({ name }) => name)),
|
||||||
|
});
|
||||||
await roleRepo
|
await roleRepo
|
||||||
.createQueryBuilder('role')
|
.createQueryBuilder('role')
|
||||||
.relation(RoleEntity, 'permissions')
|
.relation(RoleEntity, 'permissions')
|
||||||
@@ -242,7 +247,7 @@ export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends Mongo
|
|||||||
const superUsers = await manager
|
const superUsers = await manager
|
||||||
.createQueryBuilder(UserEntity, 'user')
|
.createQueryBuilder(UserEntity, 'user')
|
||||||
.leftJoinAndSelect('user.roles', 'roles')
|
.leftJoinAndSelect('user.roles', 'roles')
|
||||||
.where('roles.id IN (:...ids', { ids: [superRole.id] })
|
.where('roles.id IN (:...ids)', { ids: [superRole.id] })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
if (superUsers.length < 1) {
|
if (superUsers.length < 1) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './permission.repository';
|
||||||
|
export * from './role.repository';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { RouteOption, TagOption } from '@/modules/restful/types';
|
||||||
|
|
||||||
|
import * as controllers from './controllers';
|
||||||
|
import * as manageControllers from './controllers/manager';
|
||||||
|
|
||||||
|
export const createRbacApi = () => {
|
||||||
|
const routes: Record<'app' | 'manager', RouteOption[]> = {
|
||||||
|
app: [
|
||||||
|
{
|
||||||
|
name: 'app.rbac',
|
||||||
|
path: 'rbac',
|
||||||
|
controllers: Object.values(controllers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
manager: [
|
||||||
|
{
|
||||||
|
name: 'manage.rbac',
|
||||||
|
path: 'rbac',
|
||||||
|
controllers: Object.values(manageControllers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tags: Record<'app' | 'manager', Array<string | TagOption>> = {
|
||||||
|
app: [{ name: '角色查询', description: '查询角色信息' }],
|
||||||
|
manager: [
|
||||||
|
{ name: '角色管理', description: '管理角色信息' },
|
||||||
|
{ name: '权限管理', description: '管理权限信息' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { routes, tags };
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './permission.service';
|
||||||
|
export * from './role.service';
|
||||||
@@ -34,7 +34,7 @@ export class PermissionService extends BaseService<
|
|||||||
) {
|
) {
|
||||||
const qb = await super.buildListQB(queryBuilder, options, callback);
|
const qb = await super.buildListQB(queryBuilder, options, callback);
|
||||||
if (!isNil(options.role)) {
|
if (!isNil(options.role)) {
|
||||||
qb.andWhere('role.id IN (:...roles', { roles: [options.role] });
|
qb.andWhere('role.id IN (:...roles)', { roles: [options.role] });
|
||||||
}
|
}
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './permission.subscriber';
|
||||||
|
export * from './role.subscriber';
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import { AbilityTuple, MongoAbility, MongoQuery, RawRuleFrom } from '@casl/ability';
|
import { AbilityTuple, MongoAbility, MongoQuery, RawRuleFrom } from '@casl/ability';
|
||||||
|
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MongoAbility } from '@casl/ability';
|
||||||
|
import { FastifyRequest as Request } from 'fastify';
|
||||||
|
import { ObjectLiteral } from 'typeorm';
|
||||||
|
|
||||||
|
import { PermissionAction } from './constants';
|
||||||
|
|
||||||
|
function getRequestData(request: Request, key: string): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkOwnerPermission<T extends ObjectLiteral>(
|
||||||
|
ability: MongoAbility,
|
||||||
|
options: {
|
||||||
|
request: Request;
|
||||||
|
key?: string;
|
||||||
|
getData: (items: string[]) => Promise<T[]>;
|
||||||
|
permission?: string;
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { request, key, getData, permission } = options;
|
||||||
|
const models = await getData(getRequestData(request, key));
|
||||||
|
return models.every((model) => ability.can(permission ?? PermissionAction.OWNER, model));
|
||||||
|
}
|
||||||
@@ -19,6 +19,23 @@ export function defaultUserConfig(configure: Configure): UserConfig {
|
|||||||
3600 * 30,
|
3600 * 30,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
captcha: {
|
||||||
|
sms: {
|
||||||
|
login: {
|
||||||
|
template: configure.env.get('SMS_LOGIN_CAPTCHA_CLOUD', 'your-id'),
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
template: configure.env.get('SMS_REGISTER_CAPTCHA_CLOUD', 'your-id'),
|
||||||
|
},
|
||||||
|
'retrieve-password': {
|
||||||
|
template: configure.env.get('SMS_RETRIEVE_PASSWORD_CAPTCHA_CLOUD', 'your-id'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
register: {},
|
||||||
|
'retrieve-password': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,68 @@ export const TokenConst = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ALLOW_GUEST = 'allowGuest';
|
export const ALLOW_GUEST = 'allowGuest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码发送数据DTO验证组
|
||||||
|
*/
|
||||||
|
export enum CaptchaDtoGroups {
|
||||||
|
// 发送短信登录验证码
|
||||||
|
PHONE_LOGIN = 'phone-login',
|
||||||
|
// 发送邮件登录验证码
|
||||||
|
EMAIL_LOGIN = 'email-login',
|
||||||
|
// 发送短信注册验证码
|
||||||
|
PHONE_REGISTER = 'phone-register',
|
||||||
|
// 发送邮件注册验证码
|
||||||
|
EMAIL_REGISTER = 'email-register',
|
||||||
|
// 发送找回密码的短信验证码
|
||||||
|
PHONE_RETRIEVE_PASSWORD = 'phone-retrieve-password',
|
||||||
|
// 发送找回密码的邮件验证码
|
||||||
|
EMAIL_RETRIEVE_PASSWORD = 'email-retrieve-password',
|
||||||
|
// 发送登录用户密码重置的短信验证码
|
||||||
|
PHONE_RESET_PASSWORD = 'phone-reset-password',
|
||||||
|
// 发送登录用户密码重置的邮件验证码
|
||||||
|
EMAIL_RESET_PASSWORD = 'email-reset-password',
|
||||||
|
// 发送手机号绑定或更改的短信验证码
|
||||||
|
BOUND_PHONE = 'bound-phone',
|
||||||
|
// 发送邮箱地址绑定或更改的邮件验证码
|
||||||
|
BOUND_EMAIL = 'bound-email',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码操作类别
|
||||||
|
*/
|
||||||
|
export enum CaptchaActionType {
|
||||||
|
// 登录操作
|
||||||
|
LOGIN = 'login',
|
||||||
|
// 注册操作
|
||||||
|
REGISTER = 'register',
|
||||||
|
// 找回密码操作
|
||||||
|
RETRIEVE_PASSWORD = 'retrieve-password',
|
||||||
|
// 重置密码操作
|
||||||
|
RESET_PASSWORD = 'reset-password',
|
||||||
|
// 手机号或邮箱地址绑定操作
|
||||||
|
ACCOUNT_BOUND = 'account-bound',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码类型
|
||||||
|
*/
|
||||||
|
export enum CaptchaType {
|
||||||
|
SMS = 'sms',
|
||||||
|
EMAIL = 'email',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送验证码异步列队名称
|
||||||
|
*/
|
||||||
|
export const SEND_CAPTCHA_QUEUE = 'send-captcha-queue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码任务处理名称
|
||||||
|
*/
|
||||||
|
export const SMS_CAPTCHA_JOB = 'sms-captcha-job';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件验证码任务处理名称
|
||||||
|
*/
|
||||||
|
export const EMAIL_CAPTCHA_JOB = 'mail-captcha-job';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
SerializeOptions,
|
SerializeOptions,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ import { pick } from 'lodash';
|
|||||||
|
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
|
||||||
|
import { UserIdInterceptor } from '@/modules/user/interceptors';
|
||||||
|
|
||||||
import { Guest } from '../decorators/guest.decorator';
|
import { Guest } from '../decorators/guest.decorator';
|
||||||
import { RequestUser } from '../decorators/user.request.decorator';
|
import { RequestUser } from '../decorators/user.request.decorator';
|
||||||
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
|
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
|
||||||
@@ -83,8 +86,13 @@ export class AccountController {
|
|||||||
*/
|
*/
|
||||||
@Patch()
|
@Patch()
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@UseInterceptors(UserIdInterceptor)
|
||||||
@SerializeOptions({ groups: ['user-detail'] })
|
@SerializeOptions({ groups: ['user-detail'] })
|
||||||
async update(@RequestUser() user: ClassToPlain<UserEntity>, @Body() data: UpdateAccountDto) {
|
async update(
|
||||||
|
@RequestUser() user: ClassToPlain<UserEntity>,
|
||||||
|
@Body()
|
||||||
|
data: UpdateAccountDto,
|
||||||
|
) {
|
||||||
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
|
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './user.controller';
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
SerializeOptions,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||||
|
import { PermissionChecker } from '@/modules/rbac/types';
|
||||||
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
|
||||||
|
import { CreateUserDto, FrontedQueryUserDto, UpdateUserDto } from '@/modules/user/dtos/user.dto';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserService } from '@/modules/user/services';
|
||||||
|
import { UserModule } from '@/modules/user/user.module';
|
||||||
|
|
||||||
|
const permission: PermissionChecker = async (ab) =>
|
||||||
|
ab.can(PermissionAction.MANAGE, UserEntity.name);
|
||||||
|
|
||||||
|
@ApiTags('用户管理')
|
||||||
|
@Depends(UserModule)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('users')
|
||||||
|
export class UserController {
|
||||||
|
constructor(protected service: UserService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户列表
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-list'] })
|
||||||
|
async list(@Query() options: FrontedQueryUserDto) {
|
||||||
|
return this.service.paginate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-detail'] })
|
||||||
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.service.detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增用户
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-detail'] })
|
||||||
|
async store(@Body() data: CreateUserDto) {
|
||||||
|
return this.service.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-detail'] })
|
||||||
|
async update(@Body() data: UpdateUserDto) {
|
||||||
|
return this.service.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除用户
|
||||||
|
* @param user
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Delete()
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-list'] })
|
||||||
|
async delete(@RequestUser() user: UserEntity, @Body() data: DeleteWithTrashDto) {
|
||||||
|
const { ids, trash } = data;
|
||||||
|
if (ids.includes(user.id)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
return this.service.delete(ids, trash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量恢复用户
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
@Patch('restore')
|
||||||
|
@Permission(permission)
|
||||||
|
@SerializeOptions({ groups: ['user-list'] })
|
||||||
|
async restore(@Body() data: RestoreDto) {
|
||||||
|
const { ids } = data;
|
||||||
|
return this.service.restore(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
import {
|
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
SerializeOptions,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
import { IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { SelectTrashMode } from '@/modules/database/constants';
|
||||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||||
|
import { UserService } from '@/modules/user/services';
|
||||||
import { UserModule } from '@/modules/user/user.module';
|
import { UserModule } from '@/modules/user/user.module';
|
||||||
|
|
||||||
import { Guest } from '../decorators/guest.decorator';
|
import { Guest } from '../decorators/guest.decorator';
|
||||||
import { CreateUserDto, UpdateUserDto } from '../dtos/user.dto';
|
import { FrontedQueryUserDto } from '../dtos/user.dto';
|
||||||
import { UserService } from '../services/user.service';
|
|
||||||
|
|
||||||
@ApiTags('用户管理')
|
@ApiTags('用户查询')
|
||||||
@Depends(UserModule)
|
@Depends(UserModule)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
@@ -32,8 +24,8 @@ export class UserController {
|
|||||||
@Get()
|
@Get()
|
||||||
@Guest()
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['user-list'] })
|
@SerializeOptions({ groups: ['user-list'] })
|
||||||
async list() {
|
async list(@Query() options: FrontedQueryUserDto) {
|
||||||
return this.service.list();
|
return this.service.paginate({ ...options, trashed: SelectTrashMode.NONE });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,52 +36,6 @@ export class UserController {
|
|||||||
@Guest()
|
@Guest()
|
||||||
@SerializeOptions({ groups: ['user-detail'] })
|
@SerializeOptions({ groups: ['user-detail'] })
|
||||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||||
return this.service.detail(id);
|
return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: IsNull() }));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 新增用户
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
@Post()
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@SerializeOptions({ groups: ['user-detail'] })
|
|
||||||
async store(@Body() data: CreateUserDto) {
|
|
||||||
return this.service.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新用户
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
@Patch()
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@SerializeOptions({ groups: ['user-detail'] })
|
|
||||||
async update(@Body() data: UpdateUserDto) {
|
|
||||||
return this.service.update(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量删除用户
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
@Delete()
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@SerializeOptions({ groups: ['user-list'] })
|
|
||||||
async delete(@Body() data: DeleteWithTrashDto) {
|
|
||||||
const { ids, trash } = data;
|
|
||||||
return this.service.delete(ids, trash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量恢复用户
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
@Patch('restore')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@SerializeOptions({ groups: ['user-list'] })
|
|
||||||
async restore(@Body() data: RestoreDto) {
|
|
||||||
const { ids } = data;
|
|
||||||
return this.service.restore(ids);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,24 @@
|
|||||||
import { PickType } from '@nestjs/swagger';
|
import { OmitType, PickType } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { IsDefined, IsUUID, Length } from 'class-validator';
|
import { Length } from 'class-validator';
|
||||||
|
|
||||||
import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||||
|
|
||||||
import { UserValidateGroup } from '../constants';
|
import { CaptchaDtoGroups, UserValidateGroup } from '../constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户信息
|
* 更新用户信息
|
||||||
*/
|
*/
|
||||||
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE] })
|
@DtoValidation({ groups: [UserValidateGroup.ACCOUNT_UPDATE], whitelist: false })
|
||||||
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {
|
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {}
|
||||||
/**
|
|
||||||
* 待更新的用户ID
|
|
||||||
*/
|
|
||||||
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
|
|
||||||
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更改用户密码
|
* 更改用户密码
|
||||||
*/
|
*/
|
||||||
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
|
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
|
||||||
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
|
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
|
||||||
/**
|
|
||||||
* 待更新的用户ID
|
|
||||||
*/
|
|
||||||
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
|
|
||||||
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 旧密码:用户在更改密码时需要输入的原密码
|
* 旧密码:用户在更改密码时需要输入的原密码
|
||||||
*/
|
*/
|
||||||
@@ -40,3 +26,20 @@ export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'pla
|
|||||||
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
|
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
|
||||||
oldPassword: string;
|
oldPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对手机/邮箱绑定验证码进行验证
|
||||||
|
*/
|
||||||
|
export class AccountBoundDto extends PickType(UserCommonDto, ['code', 'phone', 'email']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定或更改手机号验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
|
||||||
|
export class PhoneBoundDto extends OmitType(AccountBoundDto, ['email'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定或更改邮箱验证
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
|
||||||
|
export class EmailBoundDto extends OmitType(AccountBoundDto, ['phone'] as const) {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PickType } from '@nestjs/swagger';
|
import { PickType } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||||
import { UserValidateGroup } from '@/modules/user/constants';
|
import { CaptchaDtoGroups, UserValidateGroup } from '@/modules/user/constants';
|
||||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,6 +9,18 @@ import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
|||||||
*/
|
*/
|
||||||
export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {}
|
export class CredentialDto extends PickType(UserCommonDto, ['credential', 'password']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过手机验证码登录
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
|
||||||
|
export class PhoneLoginDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过邮箱验证码登录
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
|
||||||
|
export class EmailLoginDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 普通方式注册用户
|
* 普通方式注册用户
|
||||||
*/
|
*/
|
||||||
@@ -19,3 +31,47 @@ export class RegisterDto extends PickType(UserCommonDto, [
|
|||||||
'password',
|
'password',
|
||||||
'plainPassword',
|
'plainPassword',
|
||||||
] as const) {}
|
] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过手机验证码注册
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
|
||||||
|
export class PhoneRegisterDto extends PickType(UserCommonDto, ['phone', 'code'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过邮件验证码注册
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_REGISTER] })
|
||||||
|
export class EmailRegisterDto extends PickType(UserCommonDto, ['email', 'code'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过登录凭证找回密码
|
||||||
|
*/
|
||||||
|
export class RetrievePasswordDto extends PickType(UserCommonDto, [
|
||||||
|
'credential',
|
||||||
|
'code',
|
||||||
|
'password',
|
||||||
|
'plainPassword',
|
||||||
|
] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过手机号找回密码
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
|
||||||
|
export class PhoneRetrievePasswordDto extends PickType(UserCommonDto, [
|
||||||
|
'phone',
|
||||||
|
'code',
|
||||||
|
'password',
|
||||||
|
'plainPassword',
|
||||||
|
] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过邮箱地址找回密码
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
|
||||||
|
export class EmailRetrievePasswordDto extends PickType(UserCommonDto, [
|
||||||
|
'email',
|
||||||
|
'code',
|
||||||
|
'password',
|
||||||
|
'plainPassword',
|
||||||
|
] as const) {}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { PickType } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||||
|
|
||||||
|
import { CaptchaDtoGroups } from '../constants';
|
||||||
|
|
||||||
|
import { UserCommonDto } from './user.common.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件或短信验证码消息
|
||||||
|
*/
|
||||||
|
export class CaptchaMessage extends PickType(UserCommonDto, ['phone', 'email']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码DTO类型
|
||||||
|
*/
|
||||||
|
export class PhoneCaptchaMessageDto extends PickType(CaptchaMessage, ['phone'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮件验证码DTO类型
|
||||||
|
*/
|
||||||
|
export class EmailCaptchaMessageDto extends PickType(CaptchaMessage, ['email'] as const) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过已登录账户发送验证码消息
|
||||||
|
*/
|
||||||
|
export class UserCaptchaMessageDto extends PickType(UserCommonDto, ['type']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过用户凭证发送验证码消息
|
||||||
|
*/
|
||||||
|
export class CredentialCaptchaMessageDto extends PickType(UserCommonDto, ['credential']) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送登录验证码短信
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_LOGIN] })
|
||||||
|
export class LoginPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送登录验证码邮件
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_LOGIN] })
|
||||||
|
export class LoginEmailCaptchaDto extends EmailCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送注册验证码短信
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
|
||||||
|
export class RegisterPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送注册验证码邮件
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.PHONE_REGISTER] })
|
||||||
|
export class RegisterEmailCaptchaDto extends EmailCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送找回密码短信
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
|
||||||
|
export class RetrievePasswordPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送找回密码邮件
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.EMAIL_RETRIEVE_PASSWORD] })
|
||||||
|
export class RetrievePasswordEmailCaptchaDto extends EmailCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送手机绑定短信
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_PHONE] })
|
||||||
|
export class BoundPhoneCaptchaDto extends PhoneCaptchaMessageDto {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送邮箱绑定邮件
|
||||||
|
*/
|
||||||
|
@DtoValidation({ groups: [CaptchaDtoGroups.BOUND_EMAIL] })
|
||||||
|
export class BoundEmailCaptchaDto extends EmailCaptchaMessageDto {}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { IsEmail, IsNotEmpty, IsOptional, Length } from 'class-validator';
|
import { IsEmail, IsEnum, IsNotEmpty, IsNumberString, IsOptional, Length } from 'class-validator';
|
||||||
|
|
||||||
import { IsMatch } from '@/modules/core/constraints/match.constraint';
|
import { IsMatch } from '@/modules/core/constraints/match.constraint';
|
||||||
import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
||||||
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
|
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
|
||||||
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
|
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
|
||||||
import { UserValidateGroup } from '@/modules/user/constants';
|
import { CaptchaType, UserValidateGroup } from '@/modules/user/constants';
|
||||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,7 +38,7 @@ export class UserCommonDto {
|
|||||||
{ entity: UserEntity, ignore: 'id', ignoreKey: 'userId' },
|
{ entity: UserEntity, ignore: 'id', ignoreKey: 'userId' },
|
||||||
{ groups: [UserValidateGroup.ACCOUNT_UPDATE], message: '该用户名已被注册' },
|
{ groups: [UserValidateGroup.ACCOUNT_UPDATE], message: '该用户名已被注册' },
|
||||||
)
|
)
|
||||||
@Length(4, 50, { always: true, message: '用户名长度必须为$constraint1到$constraint2' })
|
@Length(4, 30, { always: true, message: '用户名长度必须为$constraint1到$constraint2' })
|
||||||
@IsOptional({ groups: [UserValidateGroup.USER_UPDATE, UserValidateGroup.ACCOUNT_UPDATE] })
|
@IsOptional({ groups: [UserValidateGroup.USER_UPDATE, UserValidateGroup.ACCOUNT_UPDATE] })
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@@ -111,4 +111,11 @@ export class UserCommonDto {
|
|||||||
@IsMatch('password', false, { message: '两次输入密码不同', always: true })
|
@IsMatch('password', false, { message: '两次输入密码不同', always: true })
|
||||||
@IsNotEmpty({ message: '请再次输入密码以确认', always: true })
|
@IsNotEmpty({ message: '请再次输入密码以确认', always: true })
|
||||||
plainPassword: string;
|
plainPassword: string;
|
||||||
|
|
||||||
|
@IsNumberString(undefined, { message: '验证码必须为数字', always: true })
|
||||||
|
@Length(6, 6, { message: '验证码长度错误', always: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsEnum(CaptchaType)
|
||||||
|
type: CaptchaType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { OmitType, PartialType, PickType } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { IsDefined, IsEnum, IsUUID } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||||
|
import { toBoolean } from '@/modules/core/helpers';
|
||||||
|
import { IsDataExist } from '@/modules/database/constraints';
|
||||||
|
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
|
||||||
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
|
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
|
||||||
import { UserOrderType, UserValidateGroup } from '@/modules/user/constants';
|
import { UserOrderType, UserValidateGroup } from '@/modules/user/constants';
|
||||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||||
@@ -17,7 +21,39 @@ export class CreateUserDto extends PickType(UserCommonDto, [
|
|||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'phone',
|
'phone',
|
||||||
]) {}
|
]) {
|
||||||
|
/**
|
||||||
|
* 用户关联的角色ID列表
|
||||||
|
*/
|
||||||
|
@IsDataExist(RoleEntity, {
|
||||||
|
each: true,
|
||||||
|
always: true,
|
||||||
|
message: '角色不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, {
|
||||||
|
each: true,
|
||||||
|
always: true,
|
||||||
|
message: '角色ID格式不正确',
|
||||||
|
})
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
roles?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户直接关联的权限ID列表
|
||||||
|
*/
|
||||||
|
@IsDataExist(PermissionEntity, {
|
||||||
|
each: true,
|
||||||
|
always: true,
|
||||||
|
message: '权限不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, {
|
||||||
|
each: true,
|
||||||
|
always: true,
|
||||||
|
message: '权限ID格式不正确',
|
||||||
|
})
|
||||||
|
@IsOptional({ always: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户
|
* 更新用户
|
||||||
@@ -35,10 +71,45 @@ export class UpdateUserDto extends PartialType(CreateUserDto) {
|
|||||||
/**
|
/**
|
||||||
* 查询用户列表的Query数据验证
|
* 查询用户列表的Query数据验证
|
||||||
*/
|
*/
|
||||||
|
@DtoValidation({ type: 'query', skipMissingProperties: true })
|
||||||
export class QueryUserDto extends PaginateWithTrashedDto {
|
export class QueryUserDto extends PaginateWithTrashedDto {
|
||||||
|
/**
|
||||||
|
* 角色ID:根据角色来过滤用户
|
||||||
|
*/
|
||||||
|
@IsDataExist(RoleEntity, {
|
||||||
|
message: '角色不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, { message: '角色ID格式错误' })
|
||||||
|
@IsOptional()
|
||||||
|
role?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限ID:根据权限来过滤用户(权限包含用户关联的所有角色的权限以及直接关联的权限)
|
||||||
|
*/
|
||||||
|
@IsDataExist(PermissionEntity, {
|
||||||
|
message: '权限不存在',
|
||||||
|
})
|
||||||
|
@IsUUID(undefined, { message: '权限ID格式错误' })
|
||||||
|
@IsOptional()
|
||||||
|
permission?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排序规则:可指定用户列表的排序规则,默认为按创建时间降序排序
|
* 排序规则:可指定用户列表的排序规则,默认为按创建时间降序排序
|
||||||
*/
|
*/
|
||||||
@IsEnum(UserOrderType)
|
@IsEnum(UserOrderType)
|
||||||
|
@IsOptional()
|
||||||
orderBy?: UserOrderType;
|
orderBy?: UserOrderType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤激活状态
|
||||||
|
*/
|
||||||
|
@Transform(({ value }) => toBoolean(value))
|
||||||
|
@IsBoolean()
|
||||||
|
actived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端查询用户
|
||||||
|
*/
|
||||||
|
@DtoValidation({ type: 'query' })
|
||||||
|
export class FrontedQueryUserDto extends OmitType(QueryUserDto, ['trashed']) {}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
|
||||||
|
|
||||||
|
@Entity('user_captcha')
|
||||||
|
export class CaptchaEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ comment: '验证码' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CaptchaActionType,
|
||||||
|
comment: '验证操作类型',
|
||||||
|
})
|
||||||
|
action!: CaptchaActionType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CaptchaType,
|
||||||
|
comment: '验证码类型',
|
||||||
|
})
|
||||||
|
type!: CaptchaType;
|
||||||
|
|
||||||
|
@Column({ comment: '手机号/邮箱地址' })
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
comment: '创建时间',
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
comment: '更新时间',
|
||||||
|
})
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -63,6 +63,9 @@ export class UserEntity {
|
|||||||
@Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true })
|
@Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true })
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
@Column({ comment: '用户状态,是否激活', default: false })
|
||||||
|
actived?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户创建时间
|
* 用户创建时间
|
||||||
*/
|
*/
|
||||||
@@ -109,13 +112,16 @@ export class UserEntity {
|
|||||||
* 用户权限
|
* 用户权限
|
||||||
*/
|
*/
|
||||||
@Expose()
|
@Expose()
|
||||||
@ManyToMany(() => PermissionEntity, (permission) => permission.users, { cascade: true })
|
@ManyToMany(() => PermissionEntity, (permission) => permission.users, {
|
||||||
|
cascade: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
permissions: Relation<PermissionEntity>[];
|
permissions: Relation<PermissionEntity>[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户角色
|
* 用户角色
|
||||||
*/
|
*/
|
||||||
@Expose()
|
@Expose()
|
||||||
@ManyToMany(() => RoleEntity, (role) => role.users, { cascade: true })
|
@ManyToMany(() => RoleEntity, (role) => role.users, { cascade: true, onDelete: 'CASCADE' })
|
||||||
roles: Relation<RoleEntity>[];
|
roles: Relation<RoleEntity>[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { BaseRepository } from '@/modules/database/base/repository';
|
||||||
|
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||||
|
import { AccessTokenEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
|
@CustomRepository(AccessTokenEntity)
|
||||||
|
export class AccessTokenRepository extends BaseRepository<AccessTokenEntity> {
|
||||||
|
protected _qbName: string = 'accessToken';
|
||||||
|
|
||||||
|
buildBaseQB(): SelectQueryBuilder<AccessTokenEntity> {
|
||||||
|
return super.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from './user.repository';
|
export * from './user.repository';
|
||||||
|
export * from './access.token.repository';
|
||||||
|
export * from './refresh.token.repository';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
import { BaseRepository } from '@/modules/database/base/repository';
|
||||||
|
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||||
|
import { RefreshTokenEntity } from '@/modules/user/entities';
|
||||||
|
|
||||||
|
@CustomRepository(RefreshTokenEntity)
|
||||||
|
export class RefreshTokenRepository extends BaseRepository<RefreshTokenEntity> {
|
||||||
|
protected _qbName: string = 'refreshToken';
|
||||||
|
|
||||||
|
buildBaseQB(): SelectQueryBuilder<RefreshTokenEntity> {
|
||||||
|
return super.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export class UserRepository extends BaseRepository<UserEntity> {
|
|||||||
protected _qbName: string = 'user';
|
protected _qbName: string = 'user';
|
||||||
|
|
||||||
buildBaseQuery() {
|
buildBaseQuery() {
|
||||||
return this.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
return this.createQueryBuilder(this.qbName)
|
||||||
|
.orderBy(`${this.qbName}.createdAt`, 'DESC')
|
||||||
|
.leftJoinAndSelect(`${this.qbName}.roles`, 'roles')
|
||||||
|
.leftJoinAndSelect(`${this.qbName}.permissions`, 'permissions');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { RouteOption, TagOption } from '../restful/types';
|
import { RouteOption, TagOption } from '../restful/types';
|
||||||
|
|
||||||
import * as controllers from './controllers';
|
import * as controllers from './controllers';
|
||||||
|
import * as managerControllers from './controllers/manager';
|
||||||
|
|
||||||
export function createUserApi() {
|
export function createUserApi() {
|
||||||
const routes: Record<'app', RouteOption[]> = {
|
const routes: Record<'app' | 'manager', RouteOption[]> = {
|
||||||
app: [
|
app: [
|
||||||
{
|
{
|
||||||
name: 'app.user',
|
name: 'app.user',
|
||||||
@@ -11,13 +12,18 @@ export function createUserApi() {
|
|||||||
controllers: Object.values(controllers),
|
controllers: Object.values(controllers),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
manager: [
|
||||||
|
{
|
||||||
|
name: 'app.user',
|
||||||
|
path: 'manager',
|
||||||
|
controllers: Object.values(managerControllers),
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tags: Record<'app', (string | TagOption)[]> = {
|
const tags: Record<'app' | 'manager', (string | TagOption)[]> = {
|
||||||
app: [
|
app: [{ name: '账户操作', description: '注册登录、查看修改账户信息、修改密码等' }],
|
||||||
{ name: '用户管理', description: '对用户进行CRUD操作' },
|
manager: [{ name: '用户管理', description: '管理用户信息' }],
|
||||||
{ name: '账户操作', description: '注册登录、查看修改账户信息、修改密码等' },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { routes, tags };
|
return { routes, tags };
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { FastifyRequest as Request } from 'fastify';
|
import { FastifyRequest as Request } from 'fastify';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable import/no-extraneous-dependencies */
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { JwtModule, JwtModuleOptions, JwtService } from '@nestjs/jwt';
|
import { JwtModule, JwtModuleOptions, JwtService } from '@nestjs/jwt';
|
||||||
@@ -14,6 +13,7 @@ import { defaultUserConfig, getUserConfig } from '@/modules/user/config';
|
|||||||
import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity';
|
import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity';
|
||||||
import { RefreshTokenEntity } from '@/modules/user/entities/refresh.token.entity';
|
import { RefreshTokenEntity } from '@/modules/user/entities/refresh.token.entity';
|
||||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||||
|
import { AccessTokenRepository, RefreshTokenRepository } from '@/modules/user/repositories';
|
||||||
import { JwtConfig, JwtPayload, UserConfig } from '@/modules/user/types';
|
import { JwtConfig, JwtPayload, UserConfig } from '@/modules/user/types';
|
||||||
|
|
||||||
import { TokenConst } from '../constants';
|
import { TokenConst } from '../constants';
|
||||||
@@ -26,6 +26,8 @@ export class TokenService {
|
|||||||
constructor(
|
constructor(
|
||||||
protected configure: Configure,
|
protected configure: Configure,
|
||||||
protected jwtService: JwtService,
|
protected jwtService: JwtService,
|
||||||
|
private accessTokenRepository: AccessTokenRepository,
|
||||||
|
private refreshTokenRepository: RefreshTokenRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +43,7 @@ export class TokenService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const token = await this.generateAccessToken(user, now);
|
const token = await this.generateAccessToken(user, now);
|
||||||
await accessToken.remove();
|
await this.accessTokenRepository.remove(accessToken);
|
||||||
response.header('token', token.accessToken.value);
|
response.header('token', token.accessToken.value);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,8 @@ export class TokenService {
|
|||||||
accessToken.value = signed;
|
accessToken.value = signed;
|
||||||
accessToken.user = user;
|
accessToken.user = user;
|
||||||
accessToken.expiredAt = now.add(config.tokenExpired, 'second').toDate();
|
accessToken.expiredAt = now.add(config.tokenExpired, 'second').toDate();
|
||||||
await accessToken.save();
|
|
||||||
|
await this.accessTokenRepository.save(accessToken);
|
||||||
const refreshToken = await this.generateRefreshToken(
|
const refreshToken = await this.generateRefreshToken(
|
||||||
accessToken,
|
accessToken,
|
||||||
await getTime(this.configure),
|
await getTime(this.configure),
|
||||||
@@ -94,7 +97,7 @@ export class TokenService {
|
|||||||
);
|
);
|
||||||
refreshToken.expiredAt = now.add(config.refreshTokenExpired, 'second').toDate();
|
refreshToken.expiredAt = now.add(config.refreshTokenExpired, 'second').toDate();
|
||||||
refreshToken.accessToken = accessToken;
|
refreshToken.accessToken = accessToken;
|
||||||
await refreshToken.save();
|
await this.refreshTokenRepository.save(refreshToken);
|
||||||
return refreshToken;
|
return refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +106,10 @@ export class TokenService {
|
|||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
async checkAccessToken(value: string) {
|
async checkAccessToken(value: string) {
|
||||||
return AccessTokenEntity.findOne({ where: { value }, relations: ['user', 'refreshToken'] });
|
return this.accessTokenRepository.findOne({
|
||||||
|
where: { value },
|
||||||
|
relations: ['user', 'refreshToken'],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,9 +117,9 @@ export class TokenService {
|
|||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
async removeAccessToken(value: string) {
|
async removeAccessToken(value: string) {
|
||||||
const accessToken = await AccessTokenEntity.findOne({ where: { value } });
|
const accessToken = await this.accessTokenRepository.findOne({ where: { value } });
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
await accessToken.remove();
|
await this.accessTokenRepository.remove(accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,15 +128,15 @@ export class TokenService {
|
|||||||
* @param value
|
* @param value
|
||||||
*/
|
*/
|
||||||
async removeRefreshToken(value: string) {
|
async removeRefreshToken(value: string) {
|
||||||
const refreshToken = await RefreshTokenEntity.findOne({
|
const refreshToken = await this.refreshTokenRepository.findOne({
|
||||||
where: { value },
|
where: { value },
|
||||||
relations: ['accessToken'],
|
relations: ['accessToken'],
|
||||||
});
|
});
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
if (refreshToken.accessToken) {
|
if (refreshToken.accessToken) {
|
||||||
await refreshToken.accessToken.remove();
|
await this.accessTokenRepository.remove(refreshToken.accessToken);
|
||||||
}
|
}
|
||||||
await refreshToken.remove();
|
await this.refreshTokenRepository.remove(refreshToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { isNil } from 'lodash';
|
import { isArray, isNil } from 'lodash';
|
||||||
import { DataSource, EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { Configure } from '@/modules/config/configure';
|
import { Configure } from '@/modules/config/configure';
|
||||||
@@ -8,6 +8,8 @@ import { BaseService } from '@/modules/database/base/service';
|
|||||||
|
|
||||||
import { QueryHook } from '@/modules/database/types';
|
import { QueryHook } from '@/modules/database/types';
|
||||||
|
|
||||||
|
import { SystemRoles } from '@/modules/rbac/constants';
|
||||||
|
import { RoleRepository } from '@/modules/rbac/repositories';
|
||||||
import { UserRepository } from '@/modules/user/repositories';
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
import { CreateUserDto, QueryUserDto, UpdateUserDto } from '../dtos/user.dto';
|
import { CreateUserDto, QueryUserDto, UpdateUserDto } from '../dtos/user.dto';
|
||||||
@@ -21,26 +23,68 @@ export class UserService extends BaseService<UserEntity, UserRepository> {
|
|||||||
protected configure: Configure,
|
protected configure: Configure,
|
||||||
protected dataSource: DataSource,
|
protected dataSource: DataSource,
|
||||||
protected userRepository: UserRepository,
|
protected userRepository: UserRepository,
|
||||||
|
protected roleRepository: RoleRepository,
|
||||||
) {
|
) {
|
||||||
super(userRepository);
|
super(userRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用户
|
* 创建用户
|
||||||
|
* @param roles
|
||||||
|
* @param permissions
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
async create(data: CreateUserDto): Promise<UserEntity> {
|
async create({ roles, permissions, ...data }: CreateUserDto): Promise<UserEntity> {
|
||||||
const user = await this.userRepository.save(data, { reload: true });
|
const user = await this.userRepository.save(data, { reload: true });
|
||||||
|
if (isArray(roles) && roles.length > 0) {
|
||||||
|
await this.userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.relation('roles')
|
||||||
|
.of(user)
|
||||||
|
.add(roles);
|
||||||
|
}
|
||||||
|
if (isArray(permissions) && permissions.length > 0) {
|
||||||
|
await this.userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.relation('permissions')
|
||||||
|
.of(user)
|
||||||
|
.add(permissions);
|
||||||
|
}
|
||||||
|
await this.addUserRole(await this.detail(user.id));
|
||||||
return this.detail(user.id);
|
return this.detail(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户
|
* 更新用户
|
||||||
|
* @param roles
|
||||||
|
* @param permissions
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
async update(data: UpdateUserDto): Promise<UserEntity> {
|
async update({ roles, permissions, ...data }: UpdateUserDto): Promise<UserEntity> {
|
||||||
const updated = await this.userRepository.save(data, { reload: true });
|
const updated = await this.userRepository.save(data, { reload: true });
|
||||||
return this.detail(updated.id);
|
const user = await this.detail(updated.id);
|
||||||
|
if (
|
||||||
|
(isNil(roles) || roles.length <= 0) &&
|
||||||
|
(isNil(permissions) || permissions.length <= 0)
|
||||||
|
) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
if (isArray(roles) && roles.length > 0) {
|
||||||
|
await this.userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.relation('roles')
|
||||||
|
.of(user)
|
||||||
|
.addAndRemove(roles, user.roles ?? []);
|
||||||
|
}
|
||||||
|
if (isArray(permissions) && permissions.length > 0) {
|
||||||
|
await this.userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.relation('permissions')
|
||||||
|
.of(user)
|
||||||
|
.addAndRemove(permissions, user.permissions ?? []);
|
||||||
|
}
|
||||||
|
await this.addUserRole(await this.detail(user.id));
|
||||||
|
return this.detail(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,9 +131,32 @@ export class UserService extends BaseService<UserEntity, UserRepository> {
|
|||||||
) {
|
) {
|
||||||
const { orderBy } = options;
|
const { orderBy } = options;
|
||||||
const qb = await super.buildListQB(queryBuilder, options, callback);
|
const qb = await super.buildListQB(queryBuilder, options, callback);
|
||||||
|
if (!isNil(options.role)) {
|
||||||
|
qb.andWhere('roles.id IN (:...roles)', { roles: [options.role] });
|
||||||
|
}
|
||||||
|
if (!isNil(options.permission)) {
|
||||||
|
qb.andWhere('permissions.id IN (:...permissions)', {
|
||||||
|
permissions: [options.permission],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!isNil(orderBy)) {
|
if (!isNil(orderBy)) {
|
||||||
qb.orderBy(`${this.repository.qbName}.${orderBy}`, 'ASC');
|
qb.orderBy(`${this.repository.qbName}.${orderBy}`, 'ASC');
|
||||||
}
|
}
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async addUserRole(user: UserEntity) {
|
||||||
|
const roleRelation = this.userRepository.createQueryBuilder().relation('roles').of(user);
|
||||||
|
const roleNames = (user.roles ?? []).map((role) => role.name);
|
||||||
|
const noneUserRole = roleNames.length <= 0 || !roleNames.includes(SystemRoles.USER);
|
||||||
|
if (noneUserRole) {
|
||||||
|
const userRole = await this.roleRepository.findOne({
|
||||||
|
relations: ['users'],
|
||||||
|
where: { name: SystemRoles.USER },
|
||||||
|
});
|
||||||
|
if (!isNil(userRole)) {
|
||||||
|
await roleRelation.add(userRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
|
import { EventSubscriber, InsertEvent, LoadEvent, UpdateEvent } from 'typeorm';
|
||||||
|
|
||||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
||||||
|
import { RoleEntity } from '@/modules/rbac/entities';
|
||||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||||
import { encrypt } from '@/modules/user/utils';
|
import { encrypt } from '@/modules/user/utils';
|
||||||
|
|
||||||
@@ -36,4 +37,21 @@ export class UserSubscriber extends BaseSubscriber<UserEntity> {
|
|||||||
event.entity.password = await encrypt(this.configure, event.entity.password);
|
event.entity.password = await encrypt(this.configure, event.entity.password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async afterLoad(user: UserEntity, event: LoadEvent<any>): Promise<void> {
|
||||||
|
let permissions = user.permissions ?? [];
|
||||||
|
for (const role of user.roles ?? []) {
|
||||||
|
const roleEntity = await this.getManage(event).findOneOrFail(RoleEntity, {
|
||||||
|
relations: ['permissions'],
|
||||||
|
where: { id: role.id },
|
||||||
|
});
|
||||||
|
permissions = [...permissions, ...(roleEntity.permissions ?? [])];
|
||||||
|
}
|
||||||
|
user.permissions = permissions.reduce((o, n) => {
|
||||||
|
if (o.find(({ name }) => name === n.name)) {
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
return [...o, n];
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
|
||||||
|
import { CaptchaEntity } from '@/modules/user/entities/captcha.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户配置类型
|
* 自定义用户模块配置
|
||||||
*/
|
*/
|
||||||
export interface UserConfig {
|
export interface UserConfig {
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +13,8 @@ export interface UserConfig {
|
|||||||
* jwt token的生成配置
|
* jwt token的生成配置
|
||||||
*/
|
*/
|
||||||
jwt: JwtConfig;
|
jwt: JwtConfig;
|
||||||
|
|
||||||
|
captcha?: CustomCaptchaConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,3 +44,76 @@ export interface JwtPayload {
|
|||||||
*/
|
*/
|
||||||
iat: number;
|
iat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认用户模块配置
|
||||||
|
*/
|
||||||
|
export interface DefaultUserConfig {
|
||||||
|
hash: number;
|
||||||
|
jwt: Pick<Required<JwtConfig>, 'tokenExpired' | 'refreshTokenExpired'>;
|
||||||
|
captcha: DefaultCaptchaConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义验证码配置
|
||||||
|
*/
|
||||||
|
export interface CustomCaptchaConfig {
|
||||||
|
[CaptchaType.SMS]?: {
|
||||||
|
[key in CaptchaActionType]?: Partial<SmsCaptchaOption>;
|
||||||
|
};
|
||||||
|
[CaptchaType.EMAIL]?: {
|
||||||
|
[key in CaptchaActionType]?: Partial<EmailCaptchaOption>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认验证码配置
|
||||||
|
*/
|
||||||
|
export interface DefaultCaptchaConfig {
|
||||||
|
[CaptchaType.SMS]: {
|
||||||
|
[key in CaptchaActionType]: CaptchaOption;
|
||||||
|
};
|
||||||
|
[CaptchaType.EMAIL]: {
|
||||||
|
[key in CaptchaActionType]: Omit<EmailCaptchaOption, 'template'>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用验证码选项
|
||||||
|
*/
|
||||||
|
export interface CaptchaOption {
|
||||||
|
limit: number; // 验证码发送间隔时间
|
||||||
|
expired: number; // 验证码有效时间
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机验证码选项
|
||||||
|
*/
|
||||||
|
export interface SmsCaptchaOption extends CaptchaOption {
|
||||||
|
template: string; // 云厂商短信推送模板ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件验证码选项
|
||||||
|
*/
|
||||||
|
export interface EmailCaptchaOption extends CaptchaOption {
|
||||||
|
subject: string; // 邮件主题
|
||||||
|
template?: string; // 模板路径
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务传给消费者的数据类型
|
||||||
|
*/
|
||||||
|
export interface SendCaptchaQueueJob {
|
||||||
|
captcha: { [key in keyof CaptchaEntity]: CaptchaEntity[key] };
|
||||||
|
option: SmsCaptchaOption | EmailCaptchaOption;
|
||||||
|
otherVars?: RecordAny;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码正确性验证
|
||||||
|
*/
|
||||||
|
export type CaptchaValidate<T extends RecordAny = RecordNever> = T & {
|
||||||
|
value: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { DynamicModule, forwardRef, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ import { Configure } from '@/modules/config/configure';
|
|||||||
import { DatabaseModule } from '@/modules/database/database.module';
|
import { DatabaseModule } from '@/modules/database/database.module';
|
||||||
import { addEntities, addSubscribers } from '@/modules/database/utils';
|
import { addEntities, addSubscribers } from '@/modules/database/utils';
|
||||||
|
|
||||||
|
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||||
|
|
||||||
import * as entities from './entities';
|
import * as entities from './entities';
|
||||||
import * as guards from './guards';
|
import * as guards from './guards';
|
||||||
import * as interceptors from './interceptors';
|
import * as interceptors from './interceptors';
|
||||||
@@ -22,6 +24,7 @@ export class UserModule {
|
|||||||
module: UserModule,
|
module: UserModule,
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
forwardRef(() => RbacModule),
|
||||||
services.TokenService.JwtModuleFactory(configure),
|
services.TokenService.JwtModuleFactory(configure),
|
||||||
await addEntities(configure, Object.values(entities)),
|
await addEntities(configure, Object.values(entities)),
|
||||||
DatabaseModule.forRepository(Object.values(repositories)),
|
DatabaseModule.forRepository(Object.values(repositories)),
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ export async function encrypt(configure: Configure, password: string) {
|
|||||||
export function decrypt(password: string, hashed: string) {
|
export function decrypt(password: string, hashed: string) {
|
||||||
return bcrypt.compareSync(password, hashed);
|
return bcrypt.compareSync(password, hashed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机验证码
|
||||||
|
*/
|
||||||
|
export function generateCaptchaCode() {
|
||||||
|
return Math.random().toFixed(6).slice(-6);
|
||||||
|
}
|
||||||
|
|||||||
+68
-7
@@ -5,17 +5,25 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
|
||||||
import { existsSync } from 'fs-extra';
|
import { existsSync } from 'fs-extra';
|
||||||
import { isNil } from 'lodash';
|
import { isArray, isNil, omit } from 'lodash';
|
||||||
|
|
||||||
import { JwtAuthGuard } from '@/modules/user/guards';
|
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||||
import { UserModule } from '@/modules/user/user.module';
|
import { UserModule } from '@/modules/user/user.module';
|
||||||
|
|
||||||
import * as configs from './config';
|
import * as configs from './config';
|
||||||
import { ContentModule } from './modules/content/content.module';
|
import { ContentModule } from './modules/content/content.module';
|
||||||
import { CreateOptions } from './modules/core/types';
|
import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter';
|
||||||
|
import {
|
||||||
|
BullOptions,
|
||||||
|
CreateOptions,
|
||||||
|
QueueOptions,
|
||||||
|
RedisOption,
|
||||||
|
RedisOptions,
|
||||||
|
} from './modules/core/types';
|
||||||
import * as dbCommands from './modules/database/commands';
|
import * as dbCommands from './modules/database/commands';
|
||||||
import { DatabaseModule } from './modules/database/database.module';
|
import { DatabaseModule } from './modules/database/database.module';
|
||||||
import { MeiliModule } from './modules/meilisearch/meili.module';
|
import { MeiliModule } from './modules/meilisearch/meili.module';
|
||||||
|
import { RbacGuard } from './modules/rbac/guards/rbac.guard';
|
||||||
import { Restful } from './modules/restful/restful';
|
import { Restful } from './modules/restful/restful';
|
||||||
import { RestfulModule } from './modules/restful/restful.module';
|
import { RestfulModule } from './modules/restful/restful.module';
|
||||||
import { ApiConfig } from './modules/restful/types';
|
import { ApiConfig } from './modules/restful/types';
|
||||||
@@ -29,8 +37,12 @@ export const createOptions: CreateOptions = {
|
|||||||
await RestfulModule.forRoot(configure),
|
await RestfulModule.forRoot(configure),
|
||||||
await ContentModule.forRoot(configure),
|
await ContentModule.forRoot(configure),
|
||||||
await UserModule.forRoot(configure),
|
await UserModule.forRoot(configure),
|
||||||
|
await RbacModule.forRoot(configure),
|
||||||
],
|
],
|
||||||
globals: { guard: JwtAuthGuard },
|
globals: {
|
||||||
|
guard: RbacGuard,
|
||||||
|
filter: GlobalExceptionFilter,
|
||||||
|
},
|
||||||
builder: async ({ configure, BootModule }) => {
|
builder: async ({ configure, BootModule }) => {
|
||||||
const container = await NestFactory.create<NestFastifyApplication>(
|
const container = await NestFactory.create<NestFastifyApplication>(
|
||||||
BootModule,
|
BootModule,
|
||||||
@@ -46,12 +58,61 @@ export const createOptions: CreateOptions = {
|
|||||||
if (existsSync(join(__dirname, 'metadata.js'))) {
|
if (existsSync(join(__dirname, 'metadata.js'))) {
|
||||||
metadata = (await import(join(__dirname, 'metadata.js'))).default;
|
metadata = (await import(join(__dirname, 'metadata.js'))).default;
|
||||||
}
|
}
|
||||||
if (existsSync(join(__dirname, 'metadata.ts'))) {
|
|
||||||
metadata = (await import(join(__dirname, 'metadata.ts'))).default;
|
|
||||||
}
|
|
||||||
await restful.factoryDocs(container, metadata);
|
await restful.factoryDocs(container, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成Redis配置
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export const createRedisOptions = (options: RedisOptions): RedisOption[] | undefined => {
|
||||||
|
if (isNil(options)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const config: Array<RedisOption> = Array.isArray(options)
|
||||||
|
? options
|
||||||
|
: [{ ...options, name: 'default' }];
|
||||||
|
if (config.length < 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isNil(config.find(({ name }) => name === 'default'))) {
|
||||||
|
config[0].name = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.reduce<RedisOption[]>((o, n) => {
|
||||||
|
const names = o.map(({ name }) => name) as string[];
|
||||||
|
return names.includes(n.name) ? o : [...o, n];
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成BullMQ模块的配置
|
||||||
|
* @param options
|
||||||
|
* @param redis
|
||||||
|
*/
|
||||||
|
export const createQueueOptions = (
|
||||||
|
options: QueueOptions,
|
||||||
|
redis: Array<RedisOption>,
|
||||||
|
): BullOptions | undefined => {
|
||||||
|
if (isNil(options) || isNil(redis)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const names = redis.map(({ name }) => name);
|
||||||
|
if (names.length < 1 || !names.includes('default')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isArray(options)) {
|
||||||
|
return options.map((option) => ({
|
||||||
|
...omit(option, 'redis'),
|
||||||
|
connection: redis.find(({ name: c }) => c === (option.redis ?? 'default')),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...omit(options, 'redis'),
|
||||||
|
connection: redis.find(({ name: c }) => c === (options.redis ?? 'default')),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
|||||||
|
# Controller Tests
|
||||||
|
|
||||||
|
这个目录包含了NestJS应用程序中所有controller的完整测试用例。每个controller都有独立的测试文件,覆盖了所有的API接口和各种测试场景。
|
||||||
|
|
||||||
|
## 📁 测试文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
test/controllers/
|
||||||
|
├── README.md # 本文件
|
||||||
|
├── category.controller.test.ts # 分类查询API测试
|
||||||
|
├── tag.controller.test.ts # 标签查询API测试
|
||||||
|
├── post.controller.test.ts # 文章操作API测试
|
||||||
|
├── comment.controller.test.ts # 评论操作API测试
|
||||||
|
├── user.controller.test.ts # 用户查询API测试
|
||||||
|
├── account.controller.test.ts # 账户操作API测试
|
||||||
|
├── role.controller.test.ts # 角色查询API测试
|
||||||
|
└── manager/ # 管理后台API测试
|
||||||
|
├── category.controller.test.ts # 分类管理API测试
|
||||||
|
├── tag.controller.test.ts # 标签管理API测试
|
||||||
|
├── post.controller.test.ts # 文章管理API测试
|
||||||
|
├── comment.controller.test.ts # 评论管理API测试
|
||||||
|
├── user.controller.test.ts # 用户管理API测试
|
||||||
|
├── role.controller.test.ts # 角色管理API测试
|
||||||
|
└── permission.controller.test.ts # 权限管理API测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 测试覆盖范围
|
||||||
|
|
||||||
|
每个测试文件都包含以下类型的测试用例:
|
||||||
|
|
||||||
|
### ✅ 成功场景测试
|
||||||
|
- 正常的API调用和响应
|
||||||
|
- 有效的参数和数据
|
||||||
|
- 正确的权限验证
|
||||||
|
|
||||||
|
### ❌ 失败场景测试
|
||||||
|
- 参数验证失败
|
||||||
|
- 权限验证失败
|
||||||
|
- 数据不存在
|
||||||
|
- 业务逻辑错误
|
||||||
|
|
||||||
|
### 🔍 边界值测试
|
||||||
|
- 最大/最小值测试
|
||||||
|
- 空值和null值测试
|
||||||
|
- 特殊字符测试
|
||||||
|
- 长度限制测试
|
||||||
|
|
||||||
|
### 🔐 权限测试
|
||||||
|
- 未认证访问
|
||||||
|
- 权限不足
|
||||||
|
- Token验证
|
||||||
|
|
||||||
|
## 🚀 运行测试
|
||||||
|
|
||||||
|
### 运行所有controller测试
|
||||||
|
```bash
|
||||||
|
npm run test:controllers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行特定的测试文件
|
||||||
|
```bash
|
||||||
|
# 运行分类controller测试
|
||||||
|
npm run test:controllers category
|
||||||
|
|
||||||
|
# 运行用户相关测试
|
||||||
|
npm run test:controllers user account
|
||||||
|
|
||||||
|
# 运行管理后台测试
|
||||||
|
npm run test:controllers manager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试组
|
||||||
|
```bash
|
||||||
|
# 运行所有前台API测试
|
||||||
|
npm run test:controllers app
|
||||||
|
|
||||||
|
# 运行所有管理后台API测试
|
||||||
|
npm run test:controllers manager
|
||||||
|
|
||||||
|
# 运行所有内容相关测试
|
||||||
|
npm run test:controllers content
|
||||||
|
|
||||||
|
# 运行所有用户认证相关测试
|
||||||
|
npm run test:controllers user-auth
|
||||||
|
|
||||||
|
# 运行所有权限相关测试
|
||||||
|
npm run test:controllers rbac
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用Jest直接运行
|
||||||
|
```bash
|
||||||
|
# 运行单个测试文件
|
||||||
|
npx jest test/controllers/category.controller.test.ts
|
||||||
|
|
||||||
|
# 运行所有controller测试
|
||||||
|
npx jest test/controllers/
|
||||||
|
|
||||||
|
# 运行测试并生成覆盖率报告
|
||||||
|
npx jest test/controllers/ --coverage
|
||||||
|
|
||||||
|
# 运行测试并监听文件变化
|
||||||
|
npx jest test/controllers/ --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 测试报告
|
||||||
|
|
||||||
|
测试运行后会生成以下报告:
|
||||||
|
|
||||||
|
### 控制台输出
|
||||||
|
- 测试执行摘要
|
||||||
|
- 失败测试详情
|
||||||
|
- 性能统计
|
||||||
|
- 覆盖率摘要
|
||||||
|
|
||||||
|
### HTML报告
|
||||||
|
- 位置:`coverage/controllers/test-report.html`
|
||||||
|
- 包含详细的测试结果和覆盖率信息
|
||||||
|
|
||||||
|
### JSON报告
|
||||||
|
- 位置:`coverage/controllers/test-results.json`
|
||||||
|
- 机器可读的测试结果数据
|
||||||
|
|
||||||
|
### 覆盖率报告
|
||||||
|
- 位置:`coverage/controllers/lcov-report/index.html`
|
||||||
|
- 详细的代码覆盖率分析
|
||||||
|
|
||||||
|
## 🛠️ 测试配置
|
||||||
|
|
||||||
|
### Jest配置
|
||||||
|
测试使用专门的Jest配置文件:`test/jest.controller.config.js`
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
测试运行时会使用以下环境变量:
|
||||||
|
```bash
|
||||||
|
NODE_ENV=test
|
||||||
|
APP_PORT=3001
|
||||||
|
TEST_DB_HOST=localhost
|
||||||
|
TEST_DB_PORT=3306
|
||||||
|
TEST_DB_DATABASE=nestapp_test
|
||||||
|
TEST_DB_USERNAME=root
|
||||||
|
TEST_DB_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试数据库
|
||||||
|
- 测试使用独立的测试数据库
|
||||||
|
- 每个测试都会创建和清理自己的测试数据
|
||||||
|
- 不会影响开发或生产数据
|
||||||
|
|
||||||
|
## 📝 编写新的测试
|
||||||
|
|
||||||
|
### 测试文件模板
|
||||||
|
```typescript
|
||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/your-module';
|
||||||
|
|
||||||
|
describe('YourController', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let testData: any[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建测试数据
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
// 清理测试数据
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /endpoint', () => {
|
||||||
|
it('should return success response', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/endpoint`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
// 添加更多断言
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试数据生成
|
||||||
|
使用 `test/helpers/test-data-generator.ts` 中的工具函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
generateTestUser,
|
||||||
|
generateTestCategory,
|
||||||
|
TestDataManager
|
||||||
|
} from '../helpers/test-data-generator';
|
||||||
|
|
||||||
|
const dataManager = new TestDataManager();
|
||||||
|
|
||||||
|
// 生成测试用户
|
||||||
|
const testUser = generateTestUser('mytest');
|
||||||
|
|
||||||
|
// 生成测试分类
|
||||||
|
const testCategory = generateTestCategory('MyTestCategory');
|
||||||
|
|
||||||
|
// 添加清理任务
|
||||||
|
dataManager.getCleaner().addCleanupTask(async () => {
|
||||||
|
await userRepository.remove(testUser);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **数据库连接失败**
|
||||||
|
- 检查测试数据库是否存在
|
||||||
|
- 确认数据库连接配置正确
|
||||||
|
|
||||||
|
2. **测试超时**
|
||||||
|
- 增加Jest超时时间
|
||||||
|
- 检查是否有未关闭的数据库连接
|
||||||
|
|
||||||
|
3. **权限测试失败**
|
||||||
|
- 确认测试用户有正确的权限
|
||||||
|
- 检查认证token是否有效
|
||||||
|
|
||||||
|
4. **测试数据冲突**
|
||||||
|
- 确保每个测试使用独立的测试数据
|
||||||
|
- 检查测试数据清理是否完整
|
||||||
|
|
||||||
|
### 调试技巧
|
||||||
|
|
||||||
|
1. **启用详细日志**
|
||||||
|
```bash
|
||||||
|
DISABLE_TEST_LOGS=false npm run test:controllers
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **运行单个测试**
|
||||||
|
```bash
|
||||||
|
npx jest test/controllers/category.controller.test.ts --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **使用调试器**
|
||||||
|
```bash
|
||||||
|
node --inspect-brk node_modules/.bin/jest test/controllers/category.controller.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [Jest官方文档](https://jestjs.io/docs/getting-started)
|
||||||
|
- [NestJS测试文档](https://docs.nestjs.com/fundamentals/testing)
|
||||||
|
- [Fastify测试文档](https://www.fastify.io/docs/latest/Guides/Testing/)
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
1. 为新的controller添加对应的测试文件
|
||||||
|
2. 确保测试覆盖所有的API接口
|
||||||
|
3. 包含成功和失败场景的测试用例
|
||||||
|
4. 使用独立的测试数据,避免测试间的相互影响
|
||||||
|
5. 添加适当的注释和文档
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { getRandomString } from '@/modules/core/helpers';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/user';
|
||||||
|
|
||||||
|
describe('AccountController (App)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建测试用户
|
||||||
|
const username = getRandomString();
|
||||||
|
testUser = await userRepository.save({
|
||||||
|
username,
|
||||||
|
nickname: 'Test Account User',
|
||||||
|
password: 'password123',
|
||||||
|
email: 'testaccount@example.com',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testUser) {
|
||||||
|
await userRepository.remove(testUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /account/register', () => {
|
||||||
|
it('should register user successfully', async () => {
|
||||||
|
const username = getRandomString();
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username,
|
||||||
|
nickname: 'New User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newUser = result.json();
|
||||||
|
expect(newUser.username).toBe(username);
|
||||||
|
expect(newUser.nickname).toBe('New User');
|
||||||
|
// 不应该返回密码
|
||||||
|
expect(newUser.password).toBeUndefined();
|
||||||
|
|
||||||
|
// 清理创建的用户
|
||||||
|
await userRepository.delete(newUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing required fields', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
// missing username and password
|
||||||
|
nickname: 'Test User',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.message).toContain('用户名长度必须为4到30');
|
||||||
|
expect(response.message).toContain('密码长度不得少于8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with duplicate username', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username: testUser.username, // 使用已存在的用户名
|
||||||
|
nickname: 'Another User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('该用户名已被注册');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid email format', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username: 'test_user_email',
|
||||||
|
nickname: 'Test User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
email: 'invalid-email',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('property email should not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with password mismatch', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username: 'test_user_pwd',
|
||||||
|
nickname: 'Test User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'different_password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('两次输入密码不同');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with short password', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username: 'test_user_short',
|
||||||
|
nickname: 'Test User',
|
||||||
|
password: '123',
|
||||||
|
plainPassword: '123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('密码长度不得少于8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with long username', async () => {
|
||||||
|
const username = getRandomString(52);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username,
|
||||||
|
nickname: 'Test User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('用户名长度必须为4到30');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /account/login', () => {
|
||||||
|
it('should login successfully with username', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.username,
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.token).toBeDefined();
|
||||||
|
expect(typeof response.token).toBe('string');
|
||||||
|
|
||||||
|
// 保存token用于后续测试
|
||||||
|
authToken = response.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login successfully with email', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.email,
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with wrong password', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.username,
|
||||||
|
password: 'wrong-password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
expect(result.json().message).toContain('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent user', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: 'non-existent-user',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
expect(result.json().message).toContain('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing credentials', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
// missing credential and password
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(502);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.message).toContain('登录凭证不得为空');
|
||||||
|
expect(response.message).toContain('登录凭证长度必须为4到30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with empty credential', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: '',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(502);
|
||||||
|
expect(result.json().message).toContain('登录凭证不得为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with empty password', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.username,
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(502);
|
||||||
|
expect(result.json().message).toContain('密码长度不得少于8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /account/logout', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/logout`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout successfully', async () => {
|
||||||
|
const username = getRandomString();
|
||||||
|
const result1 = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/register`,
|
||||||
|
body: {
|
||||||
|
username,
|
||||||
|
nickname: 'New User',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const newUser = result1.json();
|
||||||
|
|
||||||
|
// 首先登录获取token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: username,
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { token } = loginResult.json();
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/logout`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
// 清理创建的用户
|
||||||
|
await userRepository.delete(newUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid token', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/logout`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /account/profile', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/account/profile`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user profile successfully', async () => {
|
||||||
|
// 确保有有效的token
|
||||||
|
if (!authToken) {
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.username,
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/account/profile`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const profile = result.json();
|
||||||
|
expect(profile.id).toBe(testUser.id);
|
||||||
|
expect(profile.username).toBe(testUser.username);
|
||||||
|
expect(profile.nickname).toBe(testUser.nickname);
|
||||||
|
expect(profile.email).toBe(testUser.email);
|
||||||
|
// 不应该返回密码
|
||||||
|
expect(profile.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid token', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/account/profile`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /account', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account`,
|
||||||
|
body: {
|
||||||
|
nickname: 'Updated Nickname',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update account info successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
nickname: 'Updated Test Account',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedUser = result.json();
|
||||||
|
expect(updatedUser.nickname).toBe('Updated Test Account');
|
||||||
|
expect(updatedUser.id).toBe(testUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update username successfully', async () => {
|
||||||
|
const randomTag = getRandomString(10);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
username: `updated-account-${randomTag}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedUser = result.json();
|
||||||
|
expect(updatedUser.username).toBe(`updated-account-${randomTag}`);
|
||||||
|
testUser.username = `updated-account-${randomTag}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with duplicate username', async () => {
|
||||||
|
// 创建另一个用户
|
||||||
|
const username = `another-account-${getRandomString()}`;
|
||||||
|
const anotherUser = await userRepository.save({
|
||||||
|
username,
|
||||||
|
nickname: 'Another Account',
|
||||||
|
password: 'password123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
username, // 尝试使用已存在的用户名
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('该用户名已被注册');
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
await userRepository.remove(anotherUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /account/change-password', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account/change-password`,
|
||||||
|
body: {
|
||||||
|
oldPassword: 'password123',
|
||||||
|
password: 'newpassword123',
|
||||||
|
plainPassword: 'newpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change password successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account/change-password`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
oldPassword: 'password123',
|
||||||
|
password: 'newpassword123',
|
||||||
|
plainPassword: 'newpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证新密码可以登录
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/account/login`,
|
||||||
|
body: {
|
||||||
|
credential: testUser.username,
|
||||||
|
password: 'newpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loginResult.statusCode).toBe(201);
|
||||||
|
|
||||||
|
// 恢复原密码以便其他测试
|
||||||
|
await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account/change-password`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
oldPassword: 'newpassword123',
|
||||||
|
password: 'password123',
|
||||||
|
plainPassword: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with wrong old password', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account/change-password`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
oldPassword: 'wrongpassword',
|
||||||
|
password: 'newpassword123',
|
||||||
|
plainPassword: 'newpassword123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(403);
|
||||||
|
expect(result.json().message).toContain('old password do not match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with password mismatch', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/account/change-password`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
oldPassword: 'password123',
|
||||||
|
password: 'newpassword123',
|
||||||
|
plainPassword: 'differentpassword',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('两次输入密码不同');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities';
|
||||||
|
import { CategoryRepository } from '@/modules/content/repositories';
|
||||||
|
import { CategoryService } from '@/modules/content/services';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/content';
|
||||||
|
|
||||||
|
describe('CategoryController (App)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let categoryRepository: CategoryRepository;
|
||||||
|
let categoryService: CategoryService;
|
||||||
|
let testCategories: CategoryEntity[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||||
|
categoryService = app.get<CategoryService>(CategoryService);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建测试分类数据
|
||||||
|
const rootCategory = await categoryRepository.save({
|
||||||
|
name: 'Test Root Category',
|
||||||
|
customOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const childCategory = await categoryRepository.save({
|
||||||
|
name: 'Test Child Category',
|
||||||
|
parent: rootCategory,
|
||||||
|
customOrder: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
testCategories = [rootCategory, childCategory];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testCategories && testCategories.length > 0) {
|
||||||
|
await categoryService.delete(testCategories.map(({ id }) => id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /category/tree', () => {
|
||||||
|
it('should return category tree successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/tree`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const categories = result.json();
|
||||||
|
expect(Array.isArray(categories)).toBe(true);
|
||||||
|
expect(categories.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return categories with tree structure', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/tree`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = result.json();
|
||||||
|
const rootCategory = categories.find((c: any) => c.name === 'Test Root Category');
|
||||||
|
expect(rootCategory).toBeDefined();
|
||||||
|
expect(rootCategory.children).toBeDefined();
|
||||||
|
expect(Array.isArray(rootCategory.children)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /category', () => {
|
||||||
|
it('should return paginated categories successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(response.meta).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return categories with valid pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?page=1&limit=10`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid page parameter', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?page=0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The current page must be greater than 1.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid limit parameter', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?limit=0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain(
|
||||||
|
'The number of data displayed per page must be greater than 1.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large page numbers gracefully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?page=999999`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large limit values', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?limit=1000`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.perPage).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /category/:id', () => {
|
||||||
|
it('should return category detail successfully', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/${category.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const categoryDetail = result.json();
|
||||||
|
expect(categoryDetail.id).toBe(category.id);
|
||||||
|
expect(categoryDetail.name).toBe(category.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID format', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/invalid-uuid`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('Validation failed (uuid is expected)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent category ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/74e655b3-b69a-42ae-a101-41c224386e74`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return category with children if exists', async () => {
|
||||||
|
const rootCategory = testCategories.find((c) => !c.parent);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/${rootCategory.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const categoryDetail = result.json();
|
||||||
|
expect(categoryDetail.children).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity, CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||||
|
import {
|
||||||
|
CategoryRepository,
|
||||||
|
CommentRepository,
|
||||||
|
PostRepository,
|
||||||
|
} from '@/modules/content/repositories';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { PermissionRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/content';
|
||||||
|
|
||||||
|
describe('CommentController (App)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let commentRepository: CommentRepository;
|
||||||
|
let postRepository: PostRepository;
|
||||||
|
let categoryRepository: CategoryRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testComments: CommentEntity[];
|
||||||
|
let testPost: PostEntity;
|
||||||
|
let testCategory: CategoryEntity;
|
||||||
|
let testUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
commentRepository = app.get<CommentRepository>(CommentRepository);
|
||||||
|
postRepository = app.get<PostRepository>(PostRepository);
|
||||||
|
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建测试用户
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'comment.create' },
|
||||||
|
});
|
||||||
|
testUser = await userRepository.save({
|
||||||
|
username: 'testuser_comment',
|
||||||
|
nickname: 'Test User Comment',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'testuser_comment',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试分类
|
||||||
|
testCategory = await categoryRepository.save({
|
||||||
|
name: 'Test Comment Category',
|
||||||
|
customOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建测试文章
|
||||||
|
testPost = await postRepository.save({
|
||||||
|
title: 'Test Post for Comments',
|
||||||
|
body: 'This is a test post for comments.',
|
||||||
|
summary: 'Test post summary',
|
||||||
|
author: testUser,
|
||||||
|
category: testCategory,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建测试评论
|
||||||
|
const parentComment = await commentRepository.save({
|
||||||
|
body: 'This is a parent comment.',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const childComment = await commentRepository.save({
|
||||||
|
body: 'This is a child comment.',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
parent: parentComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
testComments = [parentComment, childComment];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testComments && testComments.length > 0) {
|
||||||
|
await commentRepository.remove(testComments);
|
||||||
|
}
|
||||||
|
if (testPost) {
|
||||||
|
await postRepository.remove(testPost);
|
||||||
|
}
|
||||||
|
if (testCategory) {
|
||||||
|
await categoryRepository.remove(testCategory);
|
||||||
|
}
|
||||||
|
if (testUser) {
|
||||||
|
await userRepository.remove(testUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /comment/tree', () => {
|
||||||
|
it('should return comment tree for a post', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment/tree?post=${testPost.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const comments = result.json();
|
||||||
|
expect(Array.isArray(comments)).toBe(true);
|
||||||
|
|
||||||
|
// 应该包含父评论和子评论的树形结构
|
||||||
|
const parentComment = comments.find((c: any) => !c.parent);
|
||||||
|
expect(parentComment).toBeDefined();
|
||||||
|
expect(parentComment.children).toBeDefined();
|
||||||
|
expect(Array.isArray(parentComment.children)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid post UUID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment/tree?post=invalid-uuid`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing post parameter', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment/tree`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /comment', () => {
|
||||||
|
it('should return paginated comments', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(response.meta).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter comments by post', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment?post=${testPost.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
response.items.forEach((comment: any) => {
|
||||||
|
expect(comment.post.id).toBe(testPost.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return comments with pagination', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment?page=1&limit=5`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comment?page=0&limit=0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /comment', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
body: {
|
||||||
|
body: 'New test comment',
|
||||||
|
post: testPost.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create comment successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
body: 'New test comment',
|
||||||
|
post: testPost.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newComment = result.json();
|
||||||
|
expect(newComment.body).toBe('New test comment');
|
||||||
|
expect(newComment.post.id).toBe(testPost.id);
|
||||||
|
|
||||||
|
// 清理创建的评论
|
||||||
|
await commentRepository.delete(newComment.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create reply comment successfully', async () => {
|
||||||
|
const parentComment = testComments[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
body: 'Reply to parent comment',
|
||||||
|
post: testPost.id,
|
||||||
|
parent: parentComment.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const replyComment = result.json();
|
||||||
|
expect(replyComment.body).toBe('Reply to parent comment');
|
||||||
|
expect(replyComment.parent.id).toBe(parentComment.id);
|
||||||
|
|
||||||
|
// 清理创建的评论
|
||||||
|
await commentRepository.delete(replyComment.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing required fields', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
// missing body and post
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.message).toContain('Comment content cannot be empty');
|
||||||
|
expect(response.message).toContain('The post ID must be specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid post ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
body: 'Test comment',
|
||||||
|
post: 'invalid-uuid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent post', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
body: 'Test comment',
|
||||||
|
post: '74e655b3-b69a-42ae-a101-41c224386e74',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The post does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with too long comment body', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/comment`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
body: 'A'.repeat(1001), // 超过1000字符限制
|
||||||
|
post: testPost.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain(
|
||||||
|
'The length of the comment content cannot exceed 1000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity } from '@/modules/content/entities';
|
||||||
|
import { CategoryRepository } from '@/modules/content/repositories';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { PermissionRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/manager/content';
|
||||||
|
|
||||||
|
describe('CategoryController (Manager)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let categoryRepository: CategoryRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testCategories: CategoryEntity[];
|
||||||
|
let adminUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'category.manage' },
|
||||||
|
});
|
||||||
|
// 创建管理员用户
|
||||||
|
adminUser = await userRepository.save({
|
||||||
|
username: 'admin_category',
|
||||||
|
nickname: 'Admin Category',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'admin_category',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试分类数据
|
||||||
|
const rootCategory = await categoryRepository.save({
|
||||||
|
name: 'Manager Test Root Category',
|
||||||
|
customOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const childCategory = await categoryRepository.save({
|
||||||
|
name: 'Manager Test Child Category',
|
||||||
|
parent: rootCategory,
|
||||||
|
customOrder: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
testCategories = [rootCategory, childCategory];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testCategories && testCategories.length > 0) {
|
||||||
|
await categoryRepository.remove(testCategories);
|
||||||
|
}
|
||||||
|
if (adminUser) {
|
||||||
|
await userRepository.remove(adminUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /category/tree', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/tree`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return category tree with authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/tree`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const categories = result.json();
|
||||||
|
expect(Array.isArray(categories)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid token', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/tree`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /category', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return paginated categories with authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(response.meta).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category?page=1&limit=5`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /category/:id', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/${category.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return category detail with authentication', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/category/${category.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const categoryDetail = result.json();
|
||||||
|
expect(categoryDetail.id).toBe(category.id);
|
||||||
|
expect(categoryDetail.name).toBe(category.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /category', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
body: {
|
||||||
|
name: 'New Test Category',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create category successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'New Manager Test Category',
|
||||||
|
customOrder: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newCategory = result.json();
|
||||||
|
expect(newCategory.name).toBe('New Manager Test Category');
|
||||||
|
expect(newCategory.customOrder).toBe(10);
|
||||||
|
|
||||||
|
// 清理创建的分类
|
||||||
|
await categoryRepository.delete(newCategory.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create child category successfully', async () => {
|
||||||
|
const parentCategory = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'New Child Category',
|
||||||
|
parent: parentCategory.id,
|
||||||
|
customOrder: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newCategory = result.json();
|
||||||
|
expect(newCategory.name).toBe('New Child Category');
|
||||||
|
expect(newCategory.parent.id).toBe(parentCategory.id);
|
||||||
|
|
||||||
|
// 清理创建的分类
|
||||||
|
await categoryRepository.delete(newCategory.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing name', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
customOrder: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The classification name cannot be empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with duplicate name at same level', async () => {
|
||||||
|
const existingCategory = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: existingCategory.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The Category names are duplicated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid parent ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Test Category',
|
||||||
|
parent: 'invalid-uuid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent parent ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Test Category',
|
||||||
|
parent: '74e655b3-b69a-42ae-a101-41c224386e74',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The parent category does not exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with negative custom order', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Test Category',
|
||||||
|
customOrder: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The sorted value must be greater than 0.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with too long name', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'A'.repeat(26), // 超过25字符限制
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain(
|
||||||
|
'The length of the category name shall not exceed 25',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /category', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
body: {
|
||||||
|
id: category.id,
|
||||||
|
name: 'Updated Category Name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update category successfully', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: category.id,
|
||||||
|
name: 'Updated Manager Category',
|
||||||
|
customOrder: 99,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedCategory = result.json();
|
||||||
|
expect(updatedCategory.name).toBe('Updated Manager Category');
|
||||||
|
expect(updatedCategory.customOrder).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Category',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The ID must be specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid ID format', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: 'invalid-uuid',
|
||||||
|
name: 'Updated Category',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/category`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: '74e655b3-b69a-42ae-a101-41c224386e74',
|
||||||
|
name: 'Updated Category',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /category/:id', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const category = testCategories[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/category/${category.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete category successfully', async () => {
|
||||||
|
// 创建一个临时分类用于删除测试
|
||||||
|
const tempCategory = await categoryRepository.save({
|
||||||
|
name: 'Temp Category for Delete',
|
||||||
|
customOrder: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/category/${tempCategory.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证分类已被删除
|
||||||
|
const deletedCategory = await categoryRepository.findOne({
|
||||||
|
where: { id: tempCategory.id },
|
||||||
|
});
|
||||||
|
expect(deletedCategory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/category/invalid-uuid`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when deleting category with children', async () => {
|
||||||
|
const parentCategory = testCategories.find((c) => !c.parent);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/category/${parentCategory.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应该失败,因为有子分类
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity, CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||||
|
import {
|
||||||
|
CategoryRepository,
|
||||||
|
CommentRepository,
|
||||||
|
PostRepository,
|
||||||
|
} from '@/modules/content/repositories';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { PermissionRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/manager/content';
|
||||||
|
|
||||||
|
describe('CommentController (Manager)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let commentRepository: CommentRepository;
|
||||||
|
let postRepository: PostRepository;
|
||||||
|
let categoryRepository: CategoryRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testComments: CommentEntity[];
|
||||||
|
let testPost: PostEntity;
|
||||||
|
let testCategory: CategoryEntity;
|
||||||
|
let testUser: UserEntity;
|
||||||
|
let adminUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
commentRepository = app.get<CommentRepository>(CommentRepository);
|
||||||
|
postRepository = app.get<PostRepository>(PostRepository);
|
||||||
|
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建管理员用户
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'comment.manage' },
|
||||||
|
});
|
||||||
|
adminUser = await userRepository.save({
|
||||||
|
username: 'admin_comment',
|
||||||
|
nickname: 'Admin Comment',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建普通用户
|
||||||
|
testUser = await userRepository.save({
|
||||||
|
username: 'testuser_manager_comment',
|
||||||
|
nickname: 'Test User Manager Comment',
|
||||||
|
password: 'password123',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'admin_comment',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试分类
|
||||||
|
testCategory = await categoryRepository.save({
|
||||||
|
name: 'Manager Test Comment Category',
|
||||||
|
customOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建测试文章
|
||||||
|
testPost = await postRepository.save({
|
||||||
|
title: 'Manager Test Post for Comments',
|
||||||
|
body: 'This is a manager test post for comments.',
|
||||||
|
summary: 'Manager test post summary',
|
||||||
|
author: testUser,
|
||||||
|
category: testCategory,
|
||||||
|
publishedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建测试评论
|
||||||
|
const parentComment = await commentRepository.save({
|
||||||
|
body: 'This is a manager parent comment.',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const childComment = await commentRepository.save({
|
||||||
|
body: 'This is a manager child comment.',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
parent: parentComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
testComments = [parentComment, childComment];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testComments && testComments.length > 0) {
|
||||||
|
await commentRepository.remove(testComments);
|
||||||
|
}
|
||||||
|
if (testPost) {
|
||||||
|
await postRepository.remove(testPost);
|
||||||
|
}
|
||||||
|
if (testCategory) {
|
||||||
|
await categoryRepository.remove(testCategory);
|
||||||
|
}
|
||||||
|
if (testUser) {
|
||||||
|
await userRepository.remove(testUser);
|
||||||
|
}
|
||||||
|
if (adminUser) {
|
||||||
|
await userRepository.remove(adminUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /comments', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return paginated comments with authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(response.meta).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter comments by post', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments?post=${testPost.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
response.items.forEach((comment: any) => {
|
||||||
|
expect(comment.post.id).toBe(testPost.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments?page=1&limit=5`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid token', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /comments/:id', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const comment = testComments[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/comments/${comment.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /comments', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
body: {
|
||||||
|
ids: [testComments[0].id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete comments successfully', async () => {
|
||||||
|
// 创建临时评论用于删除测试
|
||||||
|
const tempComment = await commentRepository.save({
|
||||||
|
body: 'Temp comment for delete',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempComment.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证评论已被删除
|
||||||
|
const deletedComment = await commentRepository.findOne({
|
||||||
|
where: { id: tempComment.id },
|
||||||
|
});
|
||||||
|
expect(deletedComment).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete multiple comments successfully', async () => {
|
||||||
|
// 创建多个临时评论用于删除测试
|
||||||
|
const tempComment1 = await commentRepository.save({
|
||||||
|
body: 'Temp comment 1 for delete',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
const tempComment2 = await commentRepository.save({
|
||||||
|
body: 'Temp comment 2 for delete',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempComment1.id, tempComment2.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证评论已被删除
|
||||||
|
const deletedComment1 = await commentRepository.findOne({
|
||||||
|
where: { id: tempComment1.id },
|
||||||
|
});
|
||||||
|
const deletedComment2 = await commentRepository.findOne({
|
||||||
|
where: { id: tempComment2.id },
|
||||||
|
});
|
||||||
|
expect(deletedComment1).toBeNull();
|
||||||
|
expect(deletedComment2).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with empty ids array', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID in ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: ['invalid-uuid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete parent comment and its children', async () => {
|
||||||
|
// 创建父评论和子评论用于测试级联删除
|
||||||
|
const parentComment = await commentRepository.save({
|
||||||
|
body: 'Parent comment for cascade delete',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const childComment = await commentRepository.save({
|
||||||
|
body: 'Child comment for cascade delete',
|
||||||
|
post: testPost,
|
||||||
|
author: testUser,
|
||||||
|
parent: parentComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/comments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [parentComment.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证父评论和子评论都被删除
|
||||||
|
const deletedParent = await commentRepository.findOne({
|
||||||
|
where: { id: parentComment.id },
|
||||||
|
});
|
||||||
|
const deletedChild = await commentRepository.findOne({
|
||||||
|
where: { id: childComment.id },
|
||||||
|
});
|
||||||
|
expect(deletedParent).toBeNull();
|
||||||
|
expect(deletedChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CommentEntity } from '@/modules/content/entities';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { PermissionAction } from '@/modules/rbac/constants';
|
||||||
|
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||||
|
import { PermissionRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/manager/rbac';
|
||||||
|
|
||||||
|
describe('PermissionController (Manager)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testPermissions: PermissionEntity[];
|
||||||
|
let adminUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建管理员用户
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'system-manage' },
|
||||||
|
});
|
||||||
|
adminUser = await userRepository.save({
|
||||||
|
username: 'admin_permission_manager',
|
||||||
|
nickname: 'Admin Permission Manager',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'admin_permission_manager',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试权限数据
|
||||||
|
const permission1 = await permissionRepository.save({
|
||||||
|
name: 'Manager Test Permission 1',
|
||||||
|
label: 'manager.test.permission.1',
|
||||||
|
description: 'Manager test permission description 1',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: CommentEntity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permission2 = await permissionRepository.save({
|
||||||
|
name: 'Manager Test Permission 2',
|
||||||
|
label: 'manager.test.permission.2',
|
||||||
|
description: 'Manager test permission description 2',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: CommentEntity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permission3 = await permissionRepository.save({
|
||||||
|
name: 'Manager Test Permission 3',
|
||||||
|
label: 'manager.test.permission.3',
|
||||||
|
rule: {
|
||||||
|
action: PermissionAction.MANAGE,
|
||||||
|
subject: CommentEntity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
testPermissions = [permission1, permission2, permission3];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testPermissions && testPermissions.length > 0) {
|
||||||
|
await permissionRepository.remove(testPermissions);
|
||||||
|
}
|
||||||
|
if (adminUser) {
|
||||||
|
await userRepository.remove(adminUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /permissions', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return paginated permissions with authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(response.meta).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions?page=1&limit=5`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid token', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions`,
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions?page=0&limit=0`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /permissions/:id', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const permission = testPermissions[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions/${permission.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return permission detail with authentication', async () => {
|
||||||
|
const permission = testPermissions[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions/${permission.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const permissionDetail = result.json();
|
||||||
|
expect(permissionDetail.id).toBe(permission.id);
|
||||||
|
expect(permissionDetail.name).toBe(permission.name);
|
||||||
|
expect(permissionDetail.label).toBe(permission.label);
|
||||||
|
expect(permissionDetail.description).toBe(permission.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return permission without description if not set', async () => {
|
||||||
|
const permissionWithoutDesc = testPermissions.find((p) => !p.description);
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions/${permissionWithoutDesc.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const permissionDetail = result.json();
|
||||||
|
expect(permissionDetail.id).toBe(permissionWithoutDesc.id);
|
||||||
|
expect(permissionDetail.name).toBe(permissionWithoutDesc.name);
|
||||||
|
expect(permissionDetail.label).toBe(permissionWithoutDesc.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions/invalid-uuid`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent permission ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/permissions/74e655b3-b69a-42ae-a101-41c224386e74`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { CategoryEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
||||||
|
import { CategoryRepository, PostRepository, TagRepository } from '@/modules/content/repositories';
|
||||||
|
import { PostService } from '@/modules/content/services/post.service';
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { PermissionRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/manager/content';
|
||||||
|
|
||||||
|
describe('PostController (Manager)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let postRepository: PostRepository;
|
||||||
|
let categoryRepository: CategoryRepository;
|
||||||
|
let tagRepository: TagRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testPosts: PostEntity[];
|
||||||
|
let testCategory: CategoryEntity;
|
||||||
|
let testTags: TagEntity[];
|
||||||
|
let adminUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
let postService: PostService;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
postRepository = app.get<PostRepository>(PostRepository);
|
||||||
|
categoryRepository = app.get<CategoryRepository>(CategoryRepository);
|
||||||
|
tagRepository = app.get<TagRepository>(TagRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
postService = app.get<PostService>(PostService);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建管理员用户
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'post.manage' },
|
||||||
|
});
|
||||||
|
adminUser = await userRepository.save({
|
||||||
|
username: 'admin_post',
|
||||||
|
nickname: 'Admin Post',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'admin_post',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试分类
|
||||||
|
testCategory = await categoryRepository.save({
|
||||||
|
name: 'Manager Test Post Category',
|
||||||
|
customOrder: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建测试标签
|
||||||
|
const tag1 = await tagRepository.save({
|
||||||
|
name: 'Manager Test Post Tag 1',
|
||||||
|
desc: 'Manager test tag for posts',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag2 = await tagRepository.save({
|
||||||
|
name: 'Manager Test Post Tag 2',
|
||||||
|
desc: 'Another manager test tag for posts',
|
||||||
|
});
|
||||||
|
|
||||||
|
testTags = [tag1, tag2];
|
||||||
|
|
||||||
|
// 创建测试文章
|
||||||
|
const publishedPost = await postService.create(
|
||||||
|
{
|
||||||
|
title: 'Manager Published Test Post',
|
||||||
|
body: 'This is a manager published test post content.',
|
||||||
|
summary: 'Manager published test post summary',
|
||||||
|
category: testCategory.id,
|
||||||
|
tags: [tag1.id],
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
const draftPost = await postService.create(
|
||||||
|
{
|
||||||
|
title: 'Manager Draft Test Post',
|
||||||
|
body: 'This is a manager draft test post content.',
|
||||||
|
summary: 'Manager draft test post summary',
|
||||||
|
category: testCategory.id,
|
||||||
|
tags: [tag2.id],
|
||||||
|
},
|
||||||
|
adminUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
testPosts = [publishedPost, draftPost];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testPosts && testPosts.length > 0) {
|
||||||
|
await postRepository.remove(testPosts);
|
||||||
|
}
|
||||||
|
if (testTags && testTags.length > 0) {
|
||||||
|
await tagRepository.remove(testTags);
|
||||||
|
}
|
||||||
|
if (testCategory) {
|
||||||
|
await categoryRepository.remove(testCategory);
|
||||||
|
}
|
||||||
|
if (adminUser) {
|
||||||
|
await userRepository.remove(adminUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /posts', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all posts including drafts', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.items).toBeDefined();
|
||||||
|
expect(Array.isArray(response.items)).toBe(true);
|
||||||
|
|
||||||
|
// 应该包含已发布和草稿文章
|
||||||
|
const publishedPosts = response.items.filter((post: any) => post.publishedAt !== null);
|
||||||
|
const draftPosts = response.items.filter((post: any) => post.publishedAt === null);
|
||||||
|
|
||||||
|
expect(publishedPosts.length).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(draftPosts.length).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination parameters', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts?page=1&limit=5`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
expect(response.meta.currentPage).toBe(1);
|
||||||
|
expect(response.meta.perPage).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter posts by category', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts?category=${testCategory.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
response.items.forEach((post: any) => {
|
||||||
|
expect(post.category.id).toBe(testCategory.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search posts by keyword', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts?search=Manager Published`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const response = result.json();
|
||||||
|
const foundPost = response.items.find(
|
||||||
|
(post: any) =>
|
||||||
|
post.title.includes('Manager Published') ||
|
||||||
|
post.body.includes('Manager Published'),
|
||||||
|
);
|
||||||
|
expect(foundPost).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /posts/:id', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const post = testPosts[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts/${post.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return post detail including drafts', async () => {
|
||||||
|
const draftPost = testPosts.find((p) => p.publishedAt === null);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts/${draftPost.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const postDetail = result.json();
|
||||||
|
expect(postDetail.id).toBe(draftPost.id);
|
||||||
|
expect(postDetail.title).toBe(draftPost.title);
|
||||||
|
expect(postDetail.publishedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts/invalid-uuid`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent post ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `${URL_PREFIX}/posts/74e655b3-b69a-42ae-a101-41c224386e74`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /posts', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
body: {
|
||||||
|
title: 'New Manager Test Post',
|
||||||
|
body: 'New manager test post content',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create post successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: 'New Manager Test Post',
|
||||||
|
body: 'New manager test post content',
|
||||||
|
summary: 'New manager test post summary',
|
||||||
|
category: testCategory.id,
|
||||||
|
tags: [testTags[0].id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newPost = result.json();
|
||||||
|
expect(newPost.title).toBe('New Manager Test Post');
|
||||||
|
expect(newPost.author.id).toBe(adminUser.id);
|
||||||
|
|
||||||
|
// 清理创建的文章
|
||||||
|
await postRepository.delete(newPost.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create published post', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: 'New Published Manager Post',
|
||||||
|
body: 'New published manager post content',
|
||||||
|
summary: 'New published manager post summary',
|
||||||
|
category: testCategory.id,
|
||||||
|
tags: [testTags[0].id],
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newPost = result.json();
|
||||||
|
expect(newPost.title).toBe('New Published Manager Post');
|
||||||
|
expect(newPost.publishedAt).not.toBeNull();
|
||||||
|
|
||||||
|
// 清理创建的文章
|
||||||
|
await postRepository.delete(newPost.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing required fields', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: 'New Manager Test Post',
|
||||||
|
// missing body
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain(
|
||||||
|
'The content of the article must be filled in.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid category ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: 'New Manager Test Post',
|
||||||
|
body: 'New manager test post content',
|
||||||
|
category: 'invalid-uuid',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /posts', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const post = testPosts[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
body: {
|
||||||
|
id: post.id,
|
||||||
|
title: 'Updated Post Title',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update post successfully', async () => {
|
||||||
|
const post = testPosts[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: post.id,
|
||||||
|
title: 'Updated Manager Post Title',
|
||||||
|
body: 'Updated manager post content',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedPost = result.json();
|
||||||
|
expect(updatedPost.title).toBe('Updated Manager Post Title');
|
||||||
|
expect(updatedPost.body).toBe('Updated manager post content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should publish draft post', async () => {
|
||||||
|
const draftPost = testPosts.find((p) => p.publishedAt === null);
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: draftPost.id,
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedPost = result.json();
|
||||||
|
expect(updatedPost.publishedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
title: 'Updated Post',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('The article ID must be specified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: '74e655b3-b69a-42ae-a101-41c224386e74',
|
||||||
|
title: 'Updated Post',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /posts', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
body: {
|
||||||
|
ids: [testPosts[0].id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete posts successfully', async () => {
|
||||||
|
// 创建临时文章用于删除测试
|
||||||
|
const tempPost = await postRepository.save({
|
||||||
|
title: 'Temp Post for Delete',
|
||||||
|
body: 'Temporary post for deletion test',
|
||||||
|
author: adminUser,
|
||||||
|
category: testCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempPost.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证文章已被删除
|
||||||
|
const deletedPost = await postRepository.findOne({ where: { id: tempPost.id } });
|
||||||
|
expect(deletedPost).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete multiple posts successfully', async () => {
|
||||||
|
// 创建多个临时文章用于删除测试
|
||||||
|
const tempPost1 = await postRepository.save({
|
||||||
|
title: 'Temp Post 1 for Delete',
|
||||||
|
body: 'Temporary post 1 for deletion test',
|
||||||
|
author: adminUser,
|
||||||
|
category: testCategory,
|
||||||
|
});
|
||||||
|
const tempPost2 = await postRepository.save({
|
||||||
|
title: 'Temp Post 2 for Delete',
|
||||||
|
body: 'Temporary post 2 for deletion test',
|
||||||
|
author: adminUser,
|
||||||
|
category: testCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempPost1.id, tempPost2.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证文章已被删除
|
||||||
|
const deletedPost1 = await postRepository.findOne({ where: { id: tempPost1.id } });
|
||||||
|
const deletedPost2 = await postRepository.findOne({ where: { id: tempPost2.id } });
|
||||||
|
expect(deletedPost1).toBeNull();
|
||||||
|
expect(deletedPost2).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with empty ids array', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID in ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/posts`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: ['invalid-uuid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { createApp } from '@/modules/core/helpers/app';
|
||||||
|
import { App } from '@/modules/core/types';
|
||||||
|
import { RoleEntity } from '@/modules/rbac/entities';
|
||||||
|
import { PermissionRepository, RoleRepository } from '@/modules/rbac/repositories';
|
||||||
|
import { UserEntity } from '@/modules/user/entities';
|
||||||
|
import { UserRepository } from '@/modules/user/repositories';
|
||||||
|
|
||||||
|
import { createOptions } from '@/options';
|
||||||
|
|
||||||
|
const URL_PREFIX = '/api/v1/manager/rbac';
|
||||||
|
|
||||||
|
describe('RoleController (Manager)', () => {
|
||||||
|
let datasource: DataSource;
|
||||||
|
let app: NestFastifyApplication;
|
||||||
|
let roleRepository: RoleRepository;
|
||||||
|
let userRepository: UserRepository;
|
||||||
|
let testRoles: RoleEntity[];
|
||||||
|
let adminUser: UserEntity;
|
||||||
|
let authToken: string;
|
||||||
|
let permissionRepository: PermissionRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const appConfig: App = await createApp(createOptions)();
|
||||||
|
app = appConfig.container;
|
||||||
|
await app.init();
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
|
||||||
|
roleRepository = app.get<RoleRepository>(RoleRepository);
|
||||||
|
permissionRepository = app.get<PermissionRepository>(PermissionRepository);
|
||||||
|
userRepository = app.get<UserRepository>(UserRepository);
|
||||||
|
datasource = app.get<DataSource>(DataSource);
|
||||||
|
|
||||||
|
if (!datasource.isInitialized) {
|
||||||
|
await datasource.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建测试数据
|
||||||
|
await setupTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// 清理测试数据
|
||||||
|
await cleanupTestData();
|
||||||
|
await datasource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
// 创建管理员用户
|
||||||
|
const permission = await permissionRepository.findOneOrFail({
|
||||||
|
where: { name: 'role.manage' },
|
||||||
|
});
|
||||||
|
adminUser = await userRepository.save({
|
||||||
|
username: 'admin_role_manager',
|
||||||
|
nickname: 'Admin Role Manager',
|
||||||
|
password: 'password123',
|
||||||
|
permissions: [permission],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取认证token
|
||||||
|
const loginResult = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/user/account/login',
|
||||||
|
body: {
|
||||||
|
credential: 'admin_role_manager',
|
||||||
|
password: 'password123',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
authToken = loginResult.json().token;
|
||||||
|
|
||||||
|
// 创建测试角色数据
|
||||||
|
const role1 = await roleRepository.save({
|
||||||
|
name: 'Manager Test Role 1',
|
||||||
|
label: 'manager-test-role-1',
|
||||||
|
description: 'Manager test role description 1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const role2 = await roleRepository.save({
|
||||||
|
name: 'Manager Test Role 2',
|
||||||
|
label: 'manager-test-role-2',
|
||||||
|
description: 'Manager test role description 2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const role3 = await roleRepository.save({
|
||||||
|
name: 'Manager Test Role 3',
|
||||||
|
label: 'manager-test-role-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
testRoles = [role1, role2, role3];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTestData() {
|
||||||
|
if (testRoles && testRoles.length > 0) {
|
||||||
|
await roleRepository.remove(testRoles);
|
||||||
|
}
|
||||||
|
if (adminUser) {
|
||||||
|
await userRepository.remove(adminUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /roles', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
body: {
|
||||||
|
name: 'New Test Role',
|
||||||
|
label: 'new-test-role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create role successfully', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'New Manager Test Role',
|
||||||
|
label: 'new-manager-test-role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newRole = result.json();
|
||||||
|
expect(newRole.name).toBe('New Manager Test Role');
|
||||||
|
expect(newRole.label).toBe('new-manager-test-role');
|
||||||
|
|
||||||
|
// 清理创建的角色
|
||||||
|
await roleRepository.delete(newRole.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create role without description', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Role Without Description',
|
||||||
|
label: 'role-without-description',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
const newRole = result.json();
|
||||||
|
expect(newRole.name).toBe('Role Without Description');
|
||||||
|
expect(newRole.label).toBe('role-without-description');
|
||||||
|
|
||||||
|
// 清理创建的角色
|
||||||
|
await roleRepository.delete(newRole.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing name', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
label: 'test-role-label',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('名称必须填写');
|
||||||
|
expect(result.json().message).toContain('名称长度最大为100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should success with missing label', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Test Role Name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should success with duplicate name', async () => {
|
||||||
|
const existingRole = testRoles[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: existingRole.name,
|
||||||
|
label: 'different-label',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /roles', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const role = testRoles[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
body: {
|
||||||
|
id: role.id,
|
||||||
|
name: 'Updated Role Name',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update role successfully', async () => {
|
||||||
|
const role = testRoles[0];
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: role.id,
|
||||||
|
name: 'Updated Manager Role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
const updatedRole = result.json();
|
||||||
|
expect(updatedRole.name).toBe('Updated Manager Role');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: 'Updated Role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
expect(result.json().message).toContain('ID必须指定');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid ID format', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: 'invalid-uuid',
|
||||||
|
name: 'Updated Role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with non-existent ID', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
id: '74e655b3-b69a-42ae-a101-41c224386e74',
|
||||||
|
name: 'Updated Role',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /roles', () => {
|
||||||
|
it('should require authentication', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
body: {
|
||||||
|
ids: [testRoles[0].id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete roles successfully', async () => {
|
||||||
|
// 创建临时角色用于删除测试
|
||||||
|
const tempRole = await roleRepository.save({
|
||||||
|
name: 'Temp Role for Delete',
|
||||||
|
label: 'temp-role-for-delete',
|
||||||
|
description: 'Temporary role for deletion test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempRole.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证角色已被删除
|
||||||
|
const deletedRole = await roleRepository.findOne({ where: { id: tempRole.id } });
|
||||||
|
expect(deletedRole).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete multiple roles successfully', async () => {
|
||||||
|
// 创建多个临时角色用于删除测试
|
||||||
|
const tempRole1 = await roleRepository.save({
|
||||||
|
name: 'Temp Role 1 for Delete',
|
||||||
|
label: 'temp-role-1-for-delete',
|
||||||
|
});
|
||||||
|
const tempRole2 = await roleRepository.save({
|
||||||
|
name: 'Temp Role 2 for Delete',
|
||||||
|
label: 'temp-role-2-for-delete',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [tempRole1.id, tempRole2.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 验证角色已被删除
|
||||||
|
const deletedRole1 = await roleRepository.findOne({ where: { id: tempRole1.id } });
|
||||||
|
const deletedRole2 = await roleRepository.findOne({ where: { id: tempRole2.id } });
|
||||||
|
expect(deletedRole1).toBeNull();
|
||||||
|
expect(deletedRole2).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with missing ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with empty ids array', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with invalid UUID in ids', async () => {
|
||||||
|
const result = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `${URL_PREFIX}/roles`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${authToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
ids: ['invalid-uuid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user