Compare commits
35
Commits
040b0b71d5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fac8be4dd0 | ||
|
|
de01ffd7c7 | ||
|
|
91b5cce39b | ||
|
|
ef6380395c | ||
|
|
24cb8e9843 | ||
|
|
730b77448e | ||
|
|
0d11d2bd8a | ||
|
|
769973517b | ||
|
|
d2692e6a11 | ||
|
|
0f8abaec7f | ||
|
|
18b3f7b9da | ||
|
|
16a08bf280 | ||
|
|
6951ea1373 | ||
|
|
2c70ce4cdf | ||
|
|
db5b553a93 | ||
|
|
9461fc53f3 | ||
|
|
fab90132b0 | ||
|
|
bff2c4a4c7 | ||
|
|
23c8eba867 | ||
|
|
5de299c0c1 | ||
|
|
99cb459fb7 | ||
|
|
46aa8312e7 | ||
|
|
bbc9c5d6bf | ||
|
|
9d23757f45 | ||
|
|
ddb436cb9a | ||
|
|
e67711c2fe | ||
|
|
afe44ab68a | ||
|
|
d7d9777d1a | ||
|
|
5c9ecec8fc | ||
|
|
ac80ecaf4b | ||
|
|
85b2062a2a | ||
|
|
1e043718b4 | ||
|
|
b8cc65a768 | ||
|
|
6fa6e1f076 | ||
|
|
46253b3a4f |
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "3r",
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
@@ -20,6 +21,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"fastify": "^5.4.0",
|
||||
"find-up": "^7.0.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -167,6 +169,8 @@
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||
|
||||
"@casl/ability": ["@casl/ability@6.7.3", "", { "dependencies": { "@ucast/mongo2js": "^1.3.0" } }, "sha512-A4L28Ko+phJAsTDhRjzCOZWECQWN2jzZnJPnROWWHjJpyMq1h7h9ZqjwS2WbIUa3Z474X1ZPSgW0f1PboZGC0A=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
@@ -577,6 +581,14 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.1", "", { "dependencies": { "@typescript-eslint/types": "8.34.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw=="],
|
||||
|
||||
"@ucast/core": ["@ucast/core@1.10.2", "", {}, "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g=="],
|
||||
|
||||
"@ucast/js": ["@ucast/js@3.0.4", "", { "dependencies": { "@ucast/core": "^1.0.0" } }, "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q=="],
|
||||
|
||||
"@ucast/mongo": ["@ucast/mongo@2.4.3", "", { "dependencies": { "@ucast/core": "^1.4.1" } }, "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA=="],
|
||||
|
||||
"@ucast/mongo2js": ["@ucast/mongo2js@1.4.0", "", { "dependencies": { "@ucast/core": "^1.6.1", "@ucast/js": "^3.0.0", "@ucast/mongo": "^2.4.0" } }, "sha512-vR9RJ3BHlkI3RfKJIZFdVktxWvBCQRiSTeJSWN9NPxP5YJkpfXvcBWAMLwvyJx4HbB+qib5/AlSDEmQiuQyx2w=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.0", "", { "os": "android", "cpu": "arm" }, "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg=="],
|
||||
@@ -1075,7 +1087,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -2131,6 +2143,8 @@
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@nestjs/bullmq": "^11.0.2",
|
||||
"@nestjs/common": "^11.1.3",
|
||||
"@nestjs/core": "^11.1.3",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
@@ -32,6 +34,7 @@
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^5.56.0",
|
||||
"chalk": "^5.4.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -39,12 +42,16 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"email-templates": "^12.0.3",
|
||||
"fastify": "^5.4.0",
|
||||
"find-up": "^7.0.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"meilisearch": "^0.51.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"nodemailer": "^7.0.4",
|
||||
"ora": "^8.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@@ -54,6 +61,7 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.67",
|
||||
"typeorm": "^0.3.24",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.15.15",
|
||||
@@ -71,12 +79,14 @@
|
||||
"@swc/cli": "^0.7.7",
|
||||
"@swc/core": "^1.12.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/email-templates": "^10.0.4",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
|
||||
Generated
+2410
-623
File diff suppressed because it is too large
Load Diff
+22
-10
@@ -1,9 +1,14 @@
|
||||
import { Configure } from '@/modules/config/configure';
|
||||
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 { createUserApi } from '@/modules/user/routes';
|
||||
|
||||
export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
||||
const contentApi = createContentApi();
|
||||
const userApi = createUserApi();
|
||||
const rbacApi = createRbacApi();
|
||||
return {
|
||||
routes: [
|
||||
{
|
||||
@@ -12,19 +17,26 @@ export const v1 = async (configure: Configure): Promise<VersionOption> => {
|
||||
controllers: [],
|
||||
doc: {
|
||||
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: [
|
||||
{ name: '分类操作', description: '对分类进行CRUD操作' },
|
||||
{ name: '标签操作', description: '对标签进行CRUD操作' },
|
||||
{ name: '文章操作', description: '对文章进行CRUD操作' },
|
||||
{ name: '评论操作', description: '对评论进行CRUD操作' },
|
||||
...contentApi.tags.manager,
|
||||
...rbacApi.tags.manager,
|
||||
...userApi.tags.manager,
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'app.content',
|
||||
path: 'content',
|
||||
controllers: Object.values(contentControllers),
|
||||
},
|
||||
...contentApi.routes.manager,
|
||||
...rbacApi.routes.manager,
|
||||
...userApi.routes.manager,
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,7 +5,13 @@ import { ContentFactory } from '@/modules/database/factories/content.factory';
|
||||
import ContentSeeder from '@/modules/database/seeders/content.seeder';
|
||||
|
||||
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: [
|
||||
{
|
||||
type: 'mysql',
|
||||
|
||||
@@ -3,3 +3,7 @@ export * from './content.config';
|
||||
export * from './app.config';
|
||||
export * from './meili.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) => [
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
export const user = createUserConfig(() => ({}));
|
||||
export const user = createUserConfig(() => ({
|
||||
hash: 10,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import * as entities from '@/modules/content/entities';
|
||||
import { ContentRbac } from '@/modules/content/rbac';
|
||||
import * as repositories from '@/modules/content/repositories';
|
||||
import * as services 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 { UserRepository } from '@/modules/user/repositories';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
|
||||
import { defauleContentConfig } from './config';
|
||||
@@ -23,6 +26,7 @@ export class ContentModule {
|
||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||
const config = await configure.get<ContentConfig>('content', defauleContentConfig);
|
||||
const providers: ModuleMetadata['providers'] = [
|
||||
ContentRbac,
|
||||
...Object.values(services),
|
||||
...(await addSubscribers(configure, Object.values(subscribers))),
|
||||
{
|
||||
@@ -32,6 +36,7 @@ export class ContentModule {
|
||||
repositories.CategoryRepository,
|
||||
repositories.TagRepository,
|
||||
services.CategoryService,
|
||||
UserRepository,
|
||||
{ token: services.SearchService, optional: true },
|
||||
],
|
||||
useFactory(
|
||||
@@ -39,6 +44,7 @@ export class ContentModule {
|
||||
categoryRepository: repositories.CategoryRepository,
|
||||
tagRepository: repositories.TagRepository,
|
||||
categoryService: services.CategoryService,
|
||||
userRepository: UserRepository,
|
||||
searchService: SearchService,
|
||||
) {
|
||||
return new PostService(
|
||||
@@ -46,6 +52,7 @@ export class ContentModule {
|
||||
categoryRepository,
|
||||
categoryService,
|
||||
tagRepository,
|
||||
userRepository,
|
||||
searchService,
|
||||
config.searchType,
|
||||
);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||
|
||||
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 { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||
|
||||
import { ContentModule } from '../content.module';
|
||||
import { CreateCategoryDto, UpdateCategoryDto } from '../dtos/category.dto';
|
||||
import { CategoryService } from '../services';
|
||||
|
||||
@ApiTags('Category Operate')
|
||||
@ApiTags('分类查询')
|
||||
@Depends(ContentModule)
|
||||
@Controller('category')
|
||||
export class CategoryController {
|
||||
@@ -31,6 +21,7 @@ export class CategoryController {
|
||||
* Search category tree
|
||||
*/
|
||||
@Get('tree')
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['category-tree'] })
|
||||
async tree() {
|
||||
return this.service.findTrees();
|
||||
@@ -41,6 +32,7 @@ export class CategoryController {
|
||||
* @param options
|
||||
*/
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['category-list'] })
|
||||
async list(
|
||||
@Query()
|
||||
@@ -54,32 +46,9 @@ export class CategoryController {
|
||||
* @param id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['category-detail'] })
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
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 { 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 { 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 {
|
||||
CreateCommentDto,
|
||||
@@ -11,18 +26,41 @@ import {
|
||||
} from '../dtos/comment.dto';
|
||||
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)
|
||||
@Controller('comment')
|
||||
export class CommentController {
|
||||
constructor(protected service: CommentService) {}
|
||||
|
||||
/**
|
||||
* 查询评论树
|
||||
* @param options
|
||||
*/
|
||||
@Get('tree')
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['comment-tree'] })
|
||||
async tree(@Query() options: QueryCommentTreeDto) {
|
||||
return this.service.findTrees(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询评论列表
|
||||
* @param options
|
||||
*/
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['comment-list'] })
|
||||
async list(
|
||||
@Query()
|
||||
@@ -31,13 +69,26 @@ export class CommentController {
|
||||
return this.service.paginate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增评论
|
||||
* @param data
|
||||
* @param author
|
||||
*/
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@Permission(permissions.create)
|
||||
@SerializeOptions({ groups: ['comment-detail'] })
|
||||
async store(@Body() data: CreateCommentDto) {
|
||||
return this.service.create(data);
|
||||
async store(@Body() data: CreateCommentDto, @RequestUser() author: ClassToPlain<UserEntity>) {
|
||||
return this.service.create(data, author);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除评论
|
||||
* @param data
|
||||
*/
|
||||
@Delete()
|
||||
@ApiBearerAuth()
|
||||
@Permission(permissions.owner)
|
||||
@SerializeOptions({ groups: ['comment-detail'] })
|
||||
async delete(@Body() data: DeleteCommentDto) {
|
||||
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,
|
||||
} 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 { 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 { 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 { 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)
|
||||
@Controller('posts')
|
||||
export class PostController {
|
||||
constructor(private postService: PostService) {}
|
||||
|
||||
/**
|
||||
* 查询文章列表
|
||||
* @param options
|
||||
*/
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['post-list'] })
|
||||
async list(
|
||||
@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')
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
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'] })
|
||||
@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(
|
||||
@Body()
|
||||
data: CreatePostDto,
|
||||
data: FrontendCreatePostDto,
|
||||
@RequestUser() author: ClassToPlain<UserEntity>,
|
||||
) {
|
||||
return this.postService.create(data);
|
||||
return this.postService.create(data, author);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新自己发布的文章
|
||||
* @param data
|
||||
*/
|
||||
@Patch()
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
@Permission(permissions.owner)
|
||||
async update(
|
||||
@Body()
|
||||
data: UpdatePostDto,
|
||||
data: OwnerUpdatePostDto,
|
||||
) {
|
||||
return this.postService.update(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除自己发布的文章
|
||||
* @param data
|
||||
*/
|
||||
@Delete()
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
async delete(@Body() data: DeleteWithTrashDto) {
|
||||
return this.postService.delete(data.ids, data.trash);
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['post-list'] })
|
||||
@Permission(permissions.owner)
|
||||
async delete(
|
||||
@Body()
|
||||
data: DeleteWithTrashDto,
|
||||
) {
|
||||
const { ids, trash } = data;
|
||||
return this.postService.delete(ids, trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复自己发布的文章
|
||||
* @param data
|
||||
*/
|
||||
@Patch('restore')
|
||||
@SerializeOptions({ groups: ['post-detail'] })
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['post-list'] })
|
||||
@Permission(permissions.owner)
|
||||
async restore(
|
||||
@Body()
|
||||
data: RestoreDto,
|
||||
) {
|
||||
return this.postService.restore(data.ids);
|
||||
const { ids } = data;
|
||||
return this.postService.restore(ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Param, ParseUUIDPipe, 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 { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||
|
||||
import { Guest } from '@/modules/user/decorators/guest.decorator';
|
||||
|
||||
import { ContentModule } from '../content.module';
|
||||
import { CreateTagDto, UpdateTagDto } from '../dtos/tag.dto';
|
||||
import { TagService } from '../services';
|
||||
|
||||
@ApiTags('标签查询')
|
||||
@Depends(ContentModule)
|
||||
@Controller('tag')
|
||||
export class TagController {
|
||||
constructor(protected service: TagService) {}
|
||||
|
||||
/**
|
||||
* 分页查询标签列表
|
||||
* @param options
|
||||
*/
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({})
|
||||
async list(
|
||||
@Query()
|
||||
@@ -35,33 +31,14 @@ export class TagController {
|
||||
return this.service.paginate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询标签详情
|
||||
* @param id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Guest()
|
||||
@SerializeOptions({})
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
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 { IsUUID, IsDefined } from 'class-validator';
|
||||
import { ArrayMinSize, IsArray, IsDefined, IsUUID } from 'class-validator';
|
||||
|
||||
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 {
|
||||
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
|
||||
@IsDefined({ each: true, message: 'The ID must be specified' })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
@@ -23,6 +24,8 @@ import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
|
||||
import { PaginateOptions } from '@/modules/database/types';
|
||||
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
import { CategoryEntity, PostEntity, TagEntity } from '../entities';
|
||||
|
||||
/**
|
||||
@@ -90,10 +93,23 @@ export class QueryPostDto implements PaginateOptions {
|
||||
@IsUUID(undefined, { message: 'The ID format is incorrect' })
|
||||
@IsOptional()
|
||||
tag?: string;
|
||||
|
||||
/**
|
||||
* 根据文章作者ID查询
|
||||
*/
|
||||
@IsDataExist(UserEntity, {
|
||||
message: '指定的用户不存在',
|
||||
})
|
||||
@IsUUID(undefined, { message: '用户ID格式错误' })
|
||||
@IsOptional()
|
||||
author?: string;
|
||||
}
|
||||
|
||||
@DtoValidation({ groups: ['create'] })
|
||||
export class CreatePostDto {
|
||||
/**
|
||||
* 文章标题
|
||||
*/
|
||||
@MaxLength(255, {
|
||||
always: true,
|
||||
message: 'The maximum length of the article title is $constraint1',
|
||||
@@ -102,10 +118,16 @@ export class CreatePostDto {
|
||||
@IsOptional({ groups: ['update'] })
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* 文章内容
|
||||
*/
|
||||
@IsNotEmpty({ groups: ['create'], message: 'The content of the article must be filled in.' })
|
||||
@IsOptional({ groups: ['update'] })
|
||||
body: string;
|
||||
|
||||
/**
|
||||
* 文章描述
|
||||
*/
|
||||
@MaxLength(500, {
|
||||
always: true,
|
||||
message: 'The maximum length of the article description is $constraint1',
|
||||
@@ -113,12 +135,18 @@ export class CreatePostDto {
|
||||
@IsOptional({ always: true })
|
||||
summary?: string;
|
||||
|
||||
/**
|
||||
* 是否发布(发布时间)
|
||||
*/
|
||||
@Transform(({ value }) => toBoolean(value))
|
||||
@IsBoolean({ always: true })
|
||||
@ValidateIf((value) => !isNil(value.publish))
|
||||
@IsOptional({ always: true })
|
||||
publish?: boolean;
|
||||
|
||||
/**
|
||||
* SEO关键字
|
||||
*/
|
||||
@MaxLength(20, {
|
||||
always: true,
|
||||
each: true,
|
||||
@@ -127,12 +155,18 @@ export class CreatePostDto {
|
||||
@IsOptional({ always: true })
|
||||
keywords?: string[];
|
||||
|
||||
/**
|
||||
* 自定义排序
|
||||
*/
|
||||
@Transform(({ value }) => toNumber(value))
|
||||
@Min(0, { message: 'The sorted value must be greater than 0.', always: true })
|
||||
@IsInt({ always: true })
|
||||
@IsOptional({ always: true })
|
||||
customOrder?: number;
|
||||
|
||||
/**
|
||||
* 所属分类ID
|
||||
*/
|
||||
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
|
||||
@IsUUID(undefined, {
|
||||
always: true,
|
||||
@@ -141,6 +175,9 @@ export class CreatePostDto {
|
||||
@IsOptional({ always: true })
|
||||
category?: string;
|
||||
|
||||
/**
|
||||
* 关联标签ID
|
||||
*/
|
||||
@IsDataExist(TagEntity, {
|
||||
always: true,
|
||||
each: true,
|
||||
@@ -153,10 +190,30 @@ export class CreatePostDto {
|
||||
})
|
||||
@IsOptional({ always: true })
|
||||
tags?: string[];
|
||||
|
||||
/**
|
||||
* 文章作者ID:可用于在管理员发布文章时分配给其它用户,如果不设置,则作者为当前管理员
|
||||
*/
|
||||
@IsDataExist(UserEntity, {
|
||||
always: true,
|
||||
message: '用户不存在',
|
||||
})
|
||||
@IsUUID(undefined, {
|
||||
always: true,
|
||||
message: '用户ID格式不正确',
|
||||
})
|
||||
@IsOptional({ always: true })
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文章更新验证
|
||||
*/
|
||||
@DtoValidation({ groups: ['update'] })
|
||||
export class UpdatePostDto extends PartialType(CreatePostDto) {
|
||||
/**
|
||||
* 待更新ID
|
||||
*/
|
||||
@IsUUID(undefined, {
|
||||
groups: ['update'],
|
||||
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' })
|
||||
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']) {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
import type { Relation } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
@@ -10,8 +11,6 @@ import {
|
||||
TreeParent,
|
||||
} from 'typeorm';
|
||||
|
||||
import type { Relation } from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
|
||||
@Exclude()
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
import { UserEntity } from '@/modules/user/entities/UserEntity';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
|
||||
@Exclude()
|
||||
@Entity('content_comment')
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PostBodyType } from '@/modules/content/constants';
|
||||
import { CategoryEntity } from '@/modules/content/entities/category.entity';
|
||||
import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||
import { TagEntity } from '@/modules/content/entities/tag.entity';
|
||||
import { UserEntity } from '@/modules/user/entities/UserEntity';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
|
||||
@Exclude()
|
||||
@Entity('content_posts')
|
||||
@@ -39,7 +39,6 @@ export class PostEntity extends BaseEntity {
|
||||
@Column({ comment: '文章描述', nullable: true })
|
||||
summary?: string;
|
||||
|
||||
@Expose()
|
||||
@Expose()
|
||||
@Column({ comment: '关键字', type: 'simple-array', nullable: true })
|
||||
keywords?: string[];
|
||||
@@ -69,7 +68,7 @@ export class PostEntity extends BaseEntity {
|
||||
@Expose()
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({ comment: '删除时间' })
|
||||
deleteAt: Date;
|
||||
deletedAt: Date;
|
||||
|
||||
@Expose()
|
||||
commentCount: number;
|
||||
@@ -90,6 +89,7 @@ export class PostEntity extends BaseEntity {
|
||||
@OneToMany(() => CommentEntity, (comment) => comment.post, { cascade: true })
|
||||
comments: Relation<CommentEntity>[];
|
||||
|
||||
@Expose()
|
||||
@ManyToOne(() => UserEntity, (user) => user.posts, {
|
||||
nullable: false,
|
||||
onDelete: 'CASCADE',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm';
|
||||
import type { Relation } from 'typeorm';
|
||||
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { PostEntity } from '@/modules/content/entities/post.entity';
|
||||
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { QueryHook } from '@/modules/database/types';
|
||||
type FindCommentTreeOptions = FindTreeOptions & {
|
||||
addQuery?: QueryHook<CommentEntity>;
|
||||
};
|
||||
|
||||
@CustomRepository(CommentEntity)
|
||||
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
|
||||
protected _qbName = 'comment';
|
||||
|
||||
@@ -11,12 +11,13 @@ export class PostRepository extends BaseRepository<PostEntity> {
|
||||
return this.createQueryBuilder(this.qbName)
|
||||
.leftJoinAndSelect(`${this.qbName}.category`, 'category')
|
||||
.leftJoinAndSelect(`${this.qbName}.tags`, 'tags')
|
||||
.leftJoinAndSelect(`${this.qbName}.author`, 'author')
|
||||
.addSelect((query) => {
|
||||
return query
|
||||
.select('COUNT(c.id)', 'count')
|
||||
.from(CommentEntity, 'c')
|
||||
.where(`c.post.id = ${this.qbName}.id`);
|
||||
}, '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 };
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { deepMerge } from '@/modules/core/helpers';
|
||||
|
||||
export class SanitizeService {
|
||||
protected config: sanitizeHtml.IOptions = {};
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']),
|
||||
|
||||
@@ -13,11 +13,14 @@ import { CommentEntity } from '@/modules/content/entities/comment.entity';
|
||||
import { CommentRepository, PostRepository } from '@/modules/content/repositories';
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
import { treePaginate } from '@/modules/database/utils';
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
import { UserRepository } from '@/modules/user/repositories';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
|
||||
constructor(
|
||||
protected repository: CommentRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected postRepository: PostRepository,
|
||||
) {
|
||||
super(repository);
|
||||
@@ -50,7 +53,7 @@ export class CommentService extends BaseService<CommentEntity, CommentRepository
|
||||
return treePaginate(query, comments);
|
||||
}
|
||||
|
||||
async create(data: CreateCommentDto) {
|
||||
async create(data: CreateCommentDto, author: ClassToPlain<UserEntity>) {
|
||||
const parent = await this.getParent(undefined, data.parent);
|
||||
if (!isNil(parent) && parent.post.id !== data.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,
|
||||
parent,
|
||||
post: await this.getPost(data.post),
|
||||
author: await this.userRepository.findOneByOrFail({ id: author.id }),
|
||||
});
|
||||
return this.repository.findOneOrFail({
|
||||
where: { id: item.id },
|
||||
|
||||
@@ -5,7 +5,12 @@ import { isArray, isFunction, omit, pick } from 'lodash';
|
||||
import { EntityNotFoundError, In, IsNull, Not, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
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 { CategoryRepository } from '@/modules/content/repositories';
|
||||
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 { paginate } from '@/modules/database/utils';
|
||||
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
import { UserRepository } from '@/modules/user/repositories';
|
||||
|
||||
import { TagRepository } from '../repositories/tag.repository';
|
||||
|
||||
import { CategoryService } from './category.service';
|
||||
@@ -33,6 +42,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
protected categoryRepository: CategoryRepository,
|
||||
protected categoryService: CategoryService,
|
||||
protected tagRepository: TagRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected searchService?: SearchService,
|
||||
protected searchType: SearchType = 'mysql',
|
||||
) {
|
||||
@@ -61,11 +71,19 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
return item;
|
||||
}
|
||||
|
||||
async create(data: CreatePostDto) {
|
||||
/**
|
||||
* 创建文章
|
||||
* @param data
|
||||
* @param author
|
||||
*/
|
||||
async create(data: CreatePostDto | FrontendCreatePostDto, author: ClassToPlain<UserEntity>) {
|
||||
let publishedAt: Date | null;
|
||||
if (!isNil(data.publish)) {
|
||||
publishedAt = data.publish ? new Date() : null;
|
||||
}
|
||||
const authorId = isNil((data as CreatePostDto).author)
|
||||
? author.id
|
||||
: (data as CreatePostDto).author;
|
||||
const createPostDto = {
|
||||
...omit(data, ['publish']),
|
||||
category: isNil(data.category)
|
||||
@@ -73,6 +91,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
: await this.categoryRepository.findOneOrFail({ where: { id: data.category } }),
|
||||
tags: isArray(data.tags) ? await this.tagRepository.findBy({ id: In(data.tags) }) : [],
|
||||
publishedAt,
|
||||
author: await this.userRepository.findOneByOrFail({ id: authorId }),
|
||||
};
|
||||
const item = await this.repository.save(createPostDto);
|
||||
const result = await this.detail(item.id);
|
||||
@@ -82,12 +101,20 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
* @param data
|
||||
*/
|
||||
async update(data: UpdatePostDto) {
|
||||
let publishedAt: Date | null;
|
||||
if (!isNil(data.publish)) {
|
||||
publishedAt = data.publish ? new Date() : null;
|
||||
}
|
||||
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) {
|
||||
post.category = isNil(data.category)
|
||||
? null
|
||||
@@ -102,7 +129,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
.addAndRemove(data.tags, post.tags ?? []);
|
||||
}
|
||||
await this.repository.update(data.id, {
|
||||
...omit(data, ['id', 'publish', 'tags', 'category']),
|
||||
...omit(data, ['id', 'publish', 'tags', 'category', 'author']),
|
||||
publishedAt,
|
||||
});
|
||||
const result = await this.detail(data.id);
|
||||
@@ -120,8 +147,8 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
.getMany();
|
||||
let result: PostEntity[];
|
||||
if (trash) {
|
||||
const directs = items.filter((item) => !isNil(item.deleteAt));
|
||||
const softs = items.filter((item) => isNil(item.deleteAt));
|
||||
const directs = items.filter((item) => !isNil(item.deletedAt));
|
||||
const softs = items.filter((item) => isNil(item.deletedAt));
|
||||
result = [
|
||||
...(await this.repository.remove(directs)),
|
||||
...(await this.repository.softRemove(softs)),
|
||||
@@ -145,7 +172,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
.where('post.id IN (:...ids)', { ids })
|
||||
.withDeleted()
|
||||
.getMany();
|
||||
const trashes = items.filter((item) => !isNil(item.deleteAt));
|
||||
const trashes = items.filter((item) => !isNil(item.deletedAt));
|
||||
await this.searchService.update(trashes);
|
||||
const trashedIds = trashes.map((item) => item.id);
|
||||
if (trashedIds.length < 1) {
|
||||
@@ -163,7 +190,7 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
options: FindParams,
|
||||
callback?: QueryHook<PostEntity>,
|
||||
) {
|
||||
const { orderBy, isPublished, category, tag, trashed, search } = options;
|
||||
const { orderBy, isPublished, category, tag, trashed, search, author } = options;
|
||||
if (typeof isPublished === 'boolean') {
|
||||
isPublished
|
||||
? qb.where({ publishedAt: Not(IsNull()) })
|
||||
@@ -185,6 +212,9 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
if (tag) {
|
||||
qb.where('tags.id = :id', { id: tag });
|
||||
}
|
||||
if (author) {
|
||||
qb.where('author.id = :id', { id: author });
|
||||
}
|
||||
if (callback) {
|
||||
return callback(qb);
|
||||
}
|
||||
@@ -225,6 +255,6 @@ export class PostService extends BaseService<PostEntity, PostRepository, FindPar
|
||||
const tree = await this.categoryRepository.findDescendantsTree(root);
|
||||
const flatDes = await this.categoryRepository.toFlatTrees(tree.children);
|
||||
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> {
|
||||
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 page = isNil(option.page) || option.page < 1 ? 1 : option.page;
|
||||
let filter = ['deletedAt IS NULL'];
|
||||
@@ -64,7 +64,7 @@ export class SearchService implements OnModuleInit {
|
||||
.index(this.index)
|
||||
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
|
||||
return {
|
||||
item: result.hits,
|
||||
items: result.hits,
|
||||
currentPage: result.page,
|
||||
perPage: result.hitsPerPage,
|
||||
totalItems: result.estimatedTotalHits,
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getSearchItem(
|
||||
'body',
|
||||
'summary',
|
||||
'commentCount',
|
||||
'deleteAt',
|
||||
'deletedAt',
|
||||
'publishedAt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
||||
@@ -13,6 +13,7 @@ export class MatchConstraint implements ValidatorConstraintInterface {
|
||||
const relatedValue = (validationArguments.object as any)[relatedProperty];
|
||||
return (value === relatedValue) !== reverse;
|
||||
}
|
||||
|
||||
defaultMessage?(validationArguments?: ValidationArguments): string {
|
||||
const [relatedProperty, reverse] = validationArguments.constraints;
|
||||
return `${relatedProperty} and ${validationArguments.property} ${
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationOptions,
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
type ModelType = 1 | 2 | 3 | 4 | 5;
|
||||
@@ -30,6 +30,7 @@ export class PasswordConstraint implements ValidatorConstraintInterface {
|
||||
return /\d/.test(value) && /[A-Za-z]/.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
defaultMessage?(validationArguments?: ValidationArguments): string {
|
||||
return "($value) 's format error";
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';
|
||||
|
||||
export const ADDTIONAL_RELATIONSHIPS = 'addtional_relationships';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -6,11 +20,56 @@ import { Configure } from '../config/configure';
|
||||
export class CoreModule {
|
||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||
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 {
|
||||
module: CoreModule,
|
||||
global: true,
|
||||
providers: [],
|
||||
exports: [],
|
||||
providers,
|
||||
exports,
|
||||
imports,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Paramtype, SetMetadata } from '@nestjs/common';
|
||||
import { ClassTransformOptions } from 'class-transformer';
|
||||
import { ValidationOptions } from 'class-validator';
|
||||
import { ClassTransformOptions } from '@nestjs/common/interfaces/external/class-transform-options.interface';
|
||||
import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';
|
||||
|
||||
import { DTO_VALIDATION_OPTIONS } from '../contants';
|
||||
|
||||
export const DtoValidation = (
|
||||
options?: ValidationOptions & { transformOptions?: ClassTransformOptions } & {
|
||||
options?: ValidatorOptions & { transformOptions?: ClassTransformOptions } & {
|
||||
type?: Paramtype;
|
||||
},
|
||||
) => SetMetadata(DTO_VALIDATION_OPTIONS, options ?? {});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { ADDTIONAL_RELATIONSHIPS } from '../contants';
|
||||
import { DynamicRelation } from '../types';
|
||||
|
||||
export function AddRelations(relations: () => Array<DynamicRelation>) {
|
||||
return <T extends ObjectLiteral>(target: T) => {
|
||||
Reflect.defineMetadata(ADDTIONAL_RELATIONSHIPS, relations, target);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BadGatewayException, Global, Module, ModuleMetadata, Type } from '@nestjs/common';
|
||||
|
||||
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
import { useContainer } from 'class-validator';
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import { isNil, omit } from 'lodash';
|
||||
|
||||
import { ConfigModule } from '@/modules/config/config.module';
|
||||
import { Configure } from '@/modules/config/configure';
|
||||
@@ -85,6 +85,13 @@ export async function createBootModule(
|
||||
});
|
||||
}
|
||||
|
||||
if (!isNil(globals.guard)) {
|
||||
providers.push({
|
||||
provide: APP_GUARD,
|
||||
useClass: globals.guard,
|
||||
});
|
||||
}
|
||||
|
||||
return CreateModule('BootModule', () => ({
|
||||
imports,
|
||||
providers,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */
|
||||
import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
|
||||
import { IAuthGuard } from '@nestjs/passport';
|
||||
import { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { QueueOptions as BullMQOptions } from 'bullmq';
|
||||
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 { StartOptions } from 'pm2';
|
||||
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
import { CommandModule } from 'yargs';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
@@ -28,6 +36,11 @@ export interface CreateOptions {
|
||||
interceptor?: Type<any> | null;
|
||||
|
||||
filter?: Type<any> | null;
|
||||
|
||||
/**
|
||||
* 全局守卫
|
||||
*/
|
||||
guard?: Type<IAuthGuard>;
|
||||
};
|
||||
|
||||
providers?: ModuleMetadata['providers'];
|
||||
@@ -125,3 +138,121 @@ export type CommandCollection = Array<CommandItem<any, any>>;
|
||||
export interface CreateOption {
|
||||
commands: () => CommandCollection;
|
||||
}
|
||||
|
||||
export interface DynamicRelation {
|
||||
relation:
|
||||
| ReturnType<typeof OneToOne>
|
||||
| ReturnType<typeof OneToMany>
|
||||
| ReturnType<typeof ManyToOne>
|
||||
| ReturnType<typeof ManyToMany>;
|
||||
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>) {
|
||||
const qb = await this.buildItemQB(id, this.repository.buildBaseQB(), callback);
|
||||
const item = qb.getOne();
|
||||
const item = await qb.getOne();
|
||||
if (!item) {
|
||||
throw new NotFoundException(`${this.repository.qbName} ${id} NOT FOUND`);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
UpdateEvent,
|
||||
} from 'typeorm';
|
||||
|
||||
import { LoadEvent } from 'typeorm/subscriber/event/LoadEvent';
|
||||
|
||||
import { Configure } from '@/modules/config/configure';
|
||||
|
||||
import { app } from '@/modules/core/helpers/app';
|
||||
@@ -41,7 +43,7 @@ export abstract class BaseSubscriber<T extends ObjectLiteral>
|
||||
{
|
||||
protected abstract entity: ObjectType<T>;
|
||||
|
||||
protected constructor(
|
||||
constructor(
|
||||
@Optional() protected dataSource?: DataSource,
|
||||
@Optional() protected _configure?: Configure,
|
||||
) {
|
||||
@@ -72,7 +74,7 @@ export abstract class BaseSubscriber<T extends ObjectLiteral>
|
||||
return this.entity;
|
||||
}
|
||||
|
||||
async afterLoad(entity: any) {
|
||||
async afterLoad(entity: any, event?: LoadEvent<T>) {
|
||||
if ('parent' in entity && isNil(entity.depth)) {
|
||||
entity.depth = 0;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationOptions,
|
||||
registerDecorator,
|
||||
} from 'class-validator';
|
||||
import { isArray, isNil } from 'lodash';
|
||||
import { ObjectType, Repository, DataSource } from 'typeorm';
|
||||
import { DataSource, ObjectType, Repository } from 'typeorm';
|
||||
|
||||
type Condition = {
|
||||
entity: ObjectType<any>;
|
||||
@@ -52,6 +52,7 @@ export class DataExistConstraint implements ValidatorConstraintInterface {
|
||||
const item = await repo.findOne({ where: { [map]: value } });
|
||||
return !!item;
|
||||
}
|
||||
|
||||
defaultMessage?(validationArguments?: ValidationArguments): string {
|
||||
if (!validationArguments.constraints[0]) {
|
||||
return 'Model not been specified!';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { merge, isNil } from 'lodash';
|
||||
import { isNil, merge } from 'lodash';
|
||||
import { DataSource, ObjectType } from 'typeorm';
|
||||
|
||||
type Condition = {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { isNil, merge } from 'lodash';
|
||||
import { DataSource, ObjectType } from 'typeorm';
|
||||
@@ -20,6 +20,9 @@ export class UniqueConstraint implements ValidatorConstraintInterface {
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async validate(value: any, validationArguments?: ValidationArguments): Promise<boolean> {
|
||||
if (isNil(value)) {
|
||||
return true;
|
||||
}
|
||||
const config: Omit<Condition, 'entity'> = { property: validationArguments.property };
|
||||
const condition = ('entity' in validationArguments.constraints[0]
|
||||
? merge(config, validationArguments.constraints[0])
|
||||
@@ -39,6 +42,7 @@ export class UniqueConstraint implements ValidatorConstraintInterface {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
defaultMessage?(validationArguments?: ValidationArguments): string {
|
||||
const { entity, property } = validationArguments.constraints[0];
|
||||
const queryProperty = property ?? validationArguments.property;
|
||||
|
||||
@@ -18,6 +18,7 @@ type Condition = {
|
||||
|
||||
property?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@ValidatorConstraint({ name: 'dataUniqueExist', async: true })
|
||||
export class UniqueExistConstraint implements ValidatorConstraintInterface {
|
||||
@@ -48,6 +49,7 @@ export class UniqueExistConstraint implements ValidatorConstraintInterface {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage?(args?: ValidationArguments): string {
|
||||
const { entity, property } = args.constraints[0];
|
||||
const queryProperty = property ?? args.property;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { DBOptions } from './types';
|
||||
export class DatabaseModule {
|
||||
static async forRoot(configure: Configure): Promise<DynamicModule> {
|
||||
if (!configure.has('database')) {
|
||||
panic({ message: 'Database config not exists' });
|
||||
await panic({ message: 'Database config not exists' });
|
||||
}
|
||||
const { connections } = await configure.get<DBOptions>('database');
|
||||
const imports: ModuleMetadata['imports'] = [];
|
||||
@@ -46,6 +46,7 @@ export class DatabaseModule {
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
static forRepository<T extends Type<any>>(
|
||||
repositories: T[],
|
||||
datasourceName?: string,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ContentFactory = defineFactory(
|
||||
post.publishedAt = (await getTime(configure)).toDate();
|
||||
}
|
||||
if (Math.random() > 0.5) {
|
||||
post.deleteAt = (await getTime(configure)).toDate();
|
||||
post.deletedAt = (await getTime(configure)).toDate();
|
||||
}
|
||||
if (category) {
|
||||
post.category = category;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
@@ -11,7 +11,7 @@ import { TypeormMigrationRun } from '@/modules/database/commands/typeorm.migrati
|
||||
import { DBOptions } from '@/modules/database/types';
|
||||
|
||||
@Injectable()
|
||||
export class AutoMigrateResolver {
|
||||
export class AutoMigrateResolver implements OnModuleInit {
|
||||
constructor(private ref: ModuleRef) {}
|
||||
|
||||
async onModuleInit() {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export enum SystemRoles {
|
||||
USER = 'user',
|
||||
SUPER_ADMIN = 'super_admin',
|
||||
}
|
||||
|
||||
export const SYSTEM_PERMISSION = 'system-manage';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { PERMISSION_CHECKERS } from '../constants';
|
||||
import { PermissionChecker } from '../types';
|
||||
|
||||
export const Permission = (...checkers: PermissionChecker[]) =>
|
||||
SetMetadata(PERMISSION_CHECKERS, checkers);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { IsDataExist } from '@/modules/database/constraints';
|
||||
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
|
||||
|
||||
import { RoleEntity } from '../entities/role.entity';
|
||||
|
||||
@DtoValidation({ type: 'query' })
|
||||
export class QueryPermissionDto extends PaginateDto {
|
||||
/**
|
||||
* 角色ID:通过角色过滤权限列表
|
||||
*/
|
||||
@IsDataExist(RoleEntity, { groups: ['update'], message: '指定的角色不存在' })
|
||||
@IsUUID(undefined, { message: '角色ID格式错误' })
|
||||
@IsOptional()
|
||||
role?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { IsDefined, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { IsDataExist } from '@/modules/database/constraints';
|
||||
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
import { PermissionEntity } from '../entities/permission.entity';
|
||||
|
||||
@DtoValidation({ type: 'query' })
|
||||
export class QueryRoleDto extends PaginateWithTrashedDto {
|
||||
/**
|
||||
* 用户ID:通过用户过滤角色列表
|
||||
*/
|
||||
@IsDataExist(UserEntity, { groups: ['update'], message: '指定的用户不存在' })
|
||||
@IsUUID(undefined, { message: '用户ID格式错误' })
|
||||
@IsOptional()
|
||||
user?: string;
|
||||
}
|
||||
|
||||
@DtoValidation({ groups: ['create'] })
|
||||
export class CreateRoleDto {
|
||||
/**
|
||||
* 权限名称
|
||||
*/
|
||||
@MaxLength(100, { always: true, message: '名称长度最大为$constraint1' })
|
||||
@IsNotEmpty({ groups: ['create'], message: '名称必须填写' })
|
||||
@IsOptional({ groups: ['update'] })
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 权限标识:如果没有设置则在查询后为权限名称
|
||||
*/
|
||||
@MaxLength(100, { always: true, message: 'Label长度最大为$constraint1' })
|
||||
@IsOptional({ always: true })
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* 关联权限ID列表:一个角色可以关联多个权限,一个权限也可以属于多个角色
|
||||
*/
|
||||
@IsDataExist(PermissionEntity, { each: true, always: true, message: '权限不存在' })
|
||||
@IsUUID(undefined, { each: true, always: true, message: '权限ID格式不正确' })
|
||||
@IsOptional({ always: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
@DtoValidation({ groups: ['update'] })
|
||||
export class UpdateRoleDto extends PartialType(CreateRoleDto) {
|
||||
/**
|
||||
* 待更新的角色ID
|
||||
*/
|
||||
@IsUUID(undefined, { message: 'ID格式错误', groups: ['update'] })
|
||||
@IsDefined({ groups: ['update'], message: 'ID必须指定' })
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './permission.entity';
|
||||
export * from './role.entity';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AbilityTuple, MongoQuery, RawRuleFrom } from '@casl/ability';
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
import type { Relation } from 'typeorm';
|
||||
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
import { RoleEntity } from './role.entity';
|
||||
|
||||
/**
|
||||
* 权限实体
|
||||
*/
|
||||
@Exclude()
|
||||
@Entity('rbac_permission')
|
||||
export class PermissionEntity<
|
||||
P extends AbilityTuple = AbilityTuple,
|
||||
T extends MongoQuery = MongoQuery,
|
||||
> {
|
||||
/**
|
||||
* 权限ID
|
||||
*/
|
||||
@Expose()
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* 权限名称
|
||||
*/
|
||||
@Expose()
|
||||
@Column({ comment: '权限名称' })
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 权限显示名
|
||||
*/
|
||||
@Expose()
|
||||
@Column({ comment: '权限显示名', nullable: true })
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* 权限描述
|
||||
*/
|
||||
@Expose()
|
||||
@Column({ comment: '权限描述', nullable: true, type: 'text' })
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 权限规则
|
||||
*/
|
||||
@Column({ type: 'simple-json', comment: '权限规则' })
|
||||
rule: Omit<RawRuleFrom<P, T>, 'conditions'>;
|
||||
|
||||
/**
|
||||
* 权限角色
|
||||
*/
|
||||
@Expose({ groups: ['permission-list', 'permission-detail'] })
|
||||
@ManyToMany(() => RoleEntity, (role) => role.permissions)
|
||||
@JoinTable()
|
||||
roles: Relation<RoleEntity>[];
|
||||
|
||||
/**
|
||||
* 权限用户
|
||||
*/
|
||||
@ManyToMany(() => UserEntity, (user) => user.permissions)
|
||||
@JoinTable()
|
||||
users: Relation<UserEntity>[];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
import type { Relation } from 'typeorm';
|
||||
import {
|
||||
BaseEntity,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
import { PermissionEntity } from './permission.entity';
|
||||
|
||||
/**
|
||||
* 角色信息
|
||||
*/
|
||||
@Exclude()
|
||||
@Entity('rbac_role')
|
||||
export class RoleEntity extends BaseEntity {
|
||||
/**
|
||||
* 角色ID
|
||||
*/
|
||||
@Expose()
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* 角色名称
|
||||
*/
|
||||
@Expose()
|
||||
@Column({ comment: '角色名称' })
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 显示名称
|
||||
*/
|
||||
@Expose()
|
||||
@Column({ comment: '显示名称', nullable: true })
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* 角色描述
|
||||
*/
|
||||
@Expose({ groups: ['role-detail'] })
|
||||
@Column({ comment: '角色描述', nullable: true, type: 'text' })
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* 是否为不可更改的系统权限
|
||||
*/
|
||||
@Column({ comment: '是否为不可更改的系统权限', default: false })
|
||||
systemed?: boolean;
|
||||
|
||||
/**
|
||||
* 删除时间
|
||||
*/
|
||||
@Expose({ groups: ['role-detail', 'role-list'] })
|
||||
@Type(() => Date)
|
||||
@DeleteDateColumn({ comment: '删除时间' })
|
||||
deletedAt: Date;
|
||||
|
||||
/**
|
||||
* 角色权限
|
||||
*/
|
||||
@Expose({ groups: ['role-detail'] })
|
||||
@Type(() => PermissionEntity)
|
||||
@ManyToMany(() => PermissionEntity, (permission) => permission.roles, {
|
||||
cascade: true,
|
||||
eager: true,
|
||||
})
|
||||
permissions: Relation<PermissionEntity>[];
|
||||
|
||||
/**
|
||||
* 角色关联用户
|
||||
*/
|
||||
@ManyToMany(() => UserEntity, (user) => user.roles, { deferrable: 'INITIALLY IMMEDIATE' })
|
||||
@JoinTable()
|
||||
users: Relation<UserEntity>[];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createMongoAbility } from '@casl/ability';
|
||||
import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||
import { JwtAuthGuard } from '@/modules/user/guards';
|
||||
|
||||
import { UserRepository } from '@/modules/user/repositories';
|
||||
import { TokenService } from '@/modules/user/services';
|
||||
|
||||
import { PERMISSION_CHECKERS } from '../constants';
|
||||
import { RbacResolver } from '../rbac.resolver';
|
||||
|
||||
import { CheckerParams, PermissionChecker } from '../types';
|
||||
|
||||
@Injectable()
|
||||
export class RbacGuard extends JwtAuthGuard {
|
||||
constructor(
|
||||
protected reflector: Reflector,
|
||||
protected resolver: RbacResolver,
|
||||
protected tokenService: TokenService,
|
||||
protected userRepository: UserRepository,
|
||||
protected moduleRef: ModuleRef,
|
||||
) {
|
||||
super(reflector, tokenService);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const authCheck = await super.canActivate(context);
|
||||
|
||||
if (!authCheck) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const checkers = this.reflector.getAllAndOverride<PermissionChecker[]>(
|
||||
PERMISSION_CHECKERS,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (isNil(checkers) || checkers.length < 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await checkPermissions({
|
||||
resolver: this.resolver,
|
||||
repository: this.userRepository,
|
||||
checkers,
|
||||
moduleRef: this.moduleRef,
|
||||
request: context.switchToHttp().getRequest(),
|
||||
});
|
||||
if (!result) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查权限
|
||||
* @param CheckerParams
|
||||
*/
|
||||
export async function checkPermissions({
|
||||
checkers,
|
||||
moduleRef,
|
||||
resolver,
|
||||
repository,
|
||||
request,
|
||||
}: CheckerParams) {
|
||||
if (isNil(request.user)) {
|
||||
return false;
|
||||
}
|
||||
const user = await repository.findOneOrFail({
|
||||
relations: ['roles.permissions', 'permissions'],
|
||||
where: { id: request.user.id },
|
||||
});
|
||||
let permissions = user.permissions as PermissionEntity[];
|
||||
for (const role of user.roles) {
|
||||
permissions = [...permissions, ...role.permissions];
|
||||
}
|
||||
permissions = permissions.reduce((o, n) => {
|
||||
if (o.find(({ name }) => name === n.name)) {
|
||||
return o;
|
||||
}
|
||||
return [...o, n];
|
||||
}, []);
|
||||
|
||||
const ability = createMongoAbility(
|
||||
permissions.map(({ rule, name }) => {
|
||||
const resolve = resolver.permissions.find((p) => p.name === name);
|
||||
if (!isNil(resolve) && !isNil(resolve.rule.conditions)) {
|
||||
return { ...rule, conditions: resolve.rule.conditions(user) };
|
||||
}
|
||||
return rule;
|
||||
}),
|
||||
);
|
||||
const results = await Promise.all(
|
||||
checkers.map(async (checker) => checker(ability, moduleRef, request)),
|
||||
);
|
||||
return results.every((r) => !!r);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { AbilityOptions, AbilityTuple, MongoQuery, SubjectType } from '@casl/ability';
|
||||
import { InternalServerErrorException, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { isArray, isNil, omit } from 'lodash';
|
||||
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
||||
|
||||
import { PermissionEntity } from '@/modules/rbac/entities';
|
||||
|
||||
import { Configure } from '../config/configure';
|
||||
|
||||
import { deepMerge } from '../core/helpers';
|
||||
|
||||
import { UserEntity } from '../user/entities';
|
||||
|
||||
import { SYSTEM_PERMISSION, SystemRoles } from './constants';
|
||||
import { RoleEntity } from './entities/role.entity';
|
||||
import { PermissionType, Role } from './types';
|
||||
|
||||
const getSubject = <R extends SubjectType>(subject: R) => {
|
||||
if (typeof subject === 'string') {
|
||||
return subject;
|
||||
}
|
||||
if (subject.modelName) {
|
||||
return subject;
|
||||
}
|
||||
return subject.name;
|
||||
};
|
||||
|
||||
export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends MongoQuery = MongoQuery>
|
||||
implements OnApplicationBootstrap
|
||||
{
|
||||
private setuped = false;
|
||||
|
||||
private options: AbilityOptions<P, T>;
|
||||
|
||||
private _roles: Role[] = [
|
||||
{
|
||||
name: SystemRoles.USER,
|
||||
label: '普通用户',
|
||||
description: '新用户的默认角色',
|
||||
permissions: [],
|
||||
},
|
||||
{
|
||||
name: SystemRoles.SUPER_ADMIN,
|
||||
label: '超级管理员',
|
||||
description: '拥有整个系统的管理权限',
|
||||
permissions: [],
|
||||
},
|
||||
];
|
||||
|
||||
private _permissions: PermissionType<P, T>[] = [
|
||||
{
|
||||
name: SYSTEM_PERMISSION,
|
||||
label: '系统管理',
|
||||
description: '管理系统的所有功能',
|
||||
rule: {
|
||||
action: 'manage',
|
||||
subject: 'all',
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected dataSource: DataSource,
|
||||
protected configure: Configure,
|
||||
) {}
|
||||
|
||||
setOptions(options: AbilityOptions<P, T>) {
|
||||
if (!this.setuped) {
|
||||
this.options = options;
|
||||
this.setuped = true;
|
||||
console.log(this.options);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get roles() {
|
||||
return this._roles;
|
||||
}
|
||||
|
||||
get permissions() {
|
||||
return this._permissions;
|
||||
}
|
||||
|
||||
addRoles(data: Role[]) {
|
||||
this._roles = [...this._roles, ...data];
|
||||
}
|
||||
|
||||
addPermissions(data: PermissionType<P, T>[]) {
|
||||
this._permissions = [...this._permissions, ...data].map((perm) => {
|
||||
let subject: typeof perm.rule.subject;
|
||||
if (isArray(perm.rule.subject)) {
|
||||
subject = perm.rule.subject.map((v) => getSubject(v));
|
||||
} else {
|
||||
subject = getSubject(perm.rule.subject);
|
||||
}
|
||||
const rule = { ...perm.rule, subject };
|
||||
return { ...perm, rule };
|
||||
});
|
||||
}
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.dataSource.isInitialized) {
|
||||
return null;
|
||||
}
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
await this.syncRoles(queryRunner.manager);
|
||||
await this.syncPermissions(queryRunner.manager);
|
||||
await this.syncSuperAdmin(queryRunner.manager);
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
await queryRunner.rollbackTransaction();
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步角色
|
||||
* @param manager
|
||||
*/
|
||||
async syncRoles(manager: EntityManager) {
|
||||
this._roles = this.roles.reduce((o, n) => {
|
||||
if (o.map(({ name }) => name).includes(n.name)) {
|
||||
return o.map((e) => (e.name === n.name ? deepMerge(e, n, 'merge') : e));
|
||||
}
|
||||
return [...o, n];
|
||||
}, []);
|
||||
|
||||
for (const item of this.roles) {
|
||||
let role = await manager.findOne(RoleEntity, {
|
||||
relations: ['permissions'],
|
||||
where: { name: item.name },
|
||||
});
|
||||
|
||||
if (isNil(role)) {
|
||||
role = await manager.save(
|
||||
manager.create(RoleEntity, {
|
||||
name: item.name,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
systemed: true,
|
||||
}),
|
||||
{
|
||||
reload: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await manager.update(RoleEntity, role.id, { systemed: true });
|
||||
}
|
||||
}
|
||||
|
||||
const systemRoles = await manager.findBy(RoleEntity, { systemed: true });
|
||||
const toDels: string[] = [];
|
||||
for (const item of systemRoles) {
|
||||
if (isNil(this.roles.find(({ name }) => item.name === name))) {
|
||||
toDels.push(item.id);
|
||||
}
|
||||
}
|
||||
if (toDels.length > 0) {
|
||||
await manager.delete(RoleEntity, toDels);
|
||||
}
|
||||
}
|
||||
|
||||
async syncPermissions(manager: EntityManager) {
|
||||
const permissions = await manager.find(PermissionEntity);
|
||||
const roles = await manager.find(RoleEntity, {
|
||||
relations: ['permissions'],
|
||||
where: { name: Not(SystemRoles.SUPER_ADMIN) },
|
||||
});
|
||||
const roleRepo = manager.getRepository(RoleEntity);
|
||||
|
||||
// 合并并去除重复权限
|
||||
this._permissions = this.permissions.reduce(
|
||||
(o, n) => (o.map(({ name }) => name).includes(n.name) ? o : [...o, n]),
|
||||
[],
|
||||
);
|
||||
const names = this.permissions.map(({ name }) => name);
|
||||
|
||||
for (const item of this.permissions) {
|
||||
const perm = omit(item, ['conditions']);
|
||||
const old = await manager.findOneBy(PermissionEntity, { name: perm.name });
|
||||
if (isNil(old)) {
|
||||
await manager.save(manager.create(PermissionEntity, perm));
|
||||
} else {
|
||||
await manager.update(PermissionEntity, old.id, perm);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除冗余权限
|
||||
const toDels: string[] = [];
|
||||
for (const item of permissions) {
|
||||
if (!names.includes(item.name) && item.name !== SYSTEM_PERMISSION) {
|
||||
toDels.push(item.id);
|
||||
}
|
||||
}
|
||||
if (toDels.length > 0) {
|
||||
await manager.delete(PermissionEntity, toDels);
|
||||
}
|
||||
|
||||
// 同步普通角色
|
||||
for (const role of roles) {
|
||||
const rolePermissions =
|
||||
isNil(role.permissions) || role.permissions.length <= 0
|
||||
? []
|
||||
: await manager.findBy(PermissionEntity, {
|
||||
name: In(role.permissions.map(({ name }) => name)),
|
||||
});
|
||||
await roleRepo
|
||||
.createQueryBuilder('role')
|
||||
.relation(RoleEntity, 'permissions')
|
||||
.of(role)
|
||||
.addAndRemove(
|
||||
rolePermissions.map(({ id }) => id),
|
||||
(role.permissions ?? []).map(({ id }) => id),
|
||||
);
|
||||
}
|
||||
|
||||
// 同步超级管理员角色
|
||||
const superRole = await manager.findOneOrFail(RoleEntity, {
|
||||
relations: ['permissions'],
|
||||
where: { name: SystemRoles.SUPER_ADMIN },
|
||||
});
|
||||
const systemManage = await manager.findOneOrFail(PermissionEntity, {
|
||||
where: { name: SYSTEM_PERMISSION },
|
||||
});
|
||||
await roleRepo
|
||||
.createQueryBuilder('role')
|
||||
.relation(RoleEntity, 'permissions')
|
||||
.of(superRole)
|
||||
.addAndRemove(
|
||||
[systemManage.id],
|
||||
(superRole.permissions ?? []).map(({ id }) => id),
|
||||
);
|
||||
}
|
||||
|
||||
async syncSuperAdmin(manager: EntityManager) {
|
||||
const superRole = await manager.findOneOrFail(RoleEntity, {
|
||||
relations: ['permissions'],
|
||||
where: { name: SystemRoles.SUPER_ADMIN },
|
||||
});
|
||||
const superUsers = await manager
|
||||
.createQueryBuilder(UserEntity, 'user')
|
||||
.leftJoinAndSelect('user.roles', 'roles')
|
||||
.where('roles.id IN (:...ids)', { ids: [superRole.id] })
|
||||
.getMany();
|
||||
|
||||
if (superUsers.length < 1) {
|
||||
const userRepo = manager.getRepository(UserEntity);
|
||||
if ((await userRepo.count()) < 1) {
|
||||
throw new InternalServerErrorException(
|
||||
'Please add a super-admin user first before run server!',
|
||||
);
|
||||
}
|
||||
|
||||
const firstUser = await userRepo.findOneByOrFail({ id: undefined });
|
||||
await userRepo
|
||||
.createQueryBuilder('user')
|
||||
.relation(UserEntity, 'roles')
|
||||
.of(firstUser)
|
||||
.addAndRemove(
|
||||
[superRole.id],
|
||||
(firstUser.roles ?? []).map(({ id }) => id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './permission.repository';
|
||||
export * from './role.repository';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AbilityTuple, MongoQuery } from '@casl/ability';
|
||||
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseRepository } from '@/modules/database/base/repository';
|
||||
|
||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||
|
||||
import { PermissionEntity } from '../entities/permission.entity';
|
||||
|
||||
@CustomRepository(PermissionEntity)
|
||||
export class PermissionRepository extends BaseRepository<PermissionEntity> {
|
||||
protected _qbName: string = 'permission';
|
||||
|
||||
buildBaseQB(): SelectQueryBuilder<PermissionEntity<AbilityTuple, MongoQuery>> {
|
||||
return this.createQueryBuilder(this.qbName).leftJoinAndSelect(
|
||||
`${this.qbName}.roles`,
|
||||
'roles',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseRepository } from '@/modules/database/base/repository';
|
||||
|
||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||
|
||||
import { RoleEntity } from '../entities/role.entity';
|
||||
|
||||
@CustomRepository(RoleEntity)
|
||||
export class RoleRepository extends BaseRepository<RoleEntity> {
|
||||
protected _qbName: string = 'role';
|
||||
|
||||
buildBaseQB(): SelectQueryBuilder<RoleEntity> {
|
||||
return this.createQueryBuilder(this.qbName).leftJoinAndSelect(
|
||||
`${this.qbName}.permissions`,
|
||||
'permissions',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AbilityTuple, MongoQuery } from '@casl/ability';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
|
||||
import { QueryPermissionDto } from '../dtos/permission.dto';
|
||||
import { PermissionEntity } from '../entities/permission.entity';
|
||||
import { PermissionRepository } from '../repositories/permission.repository';
|
||||
|
||||
type FindParams = {
|
||||
[key in keyof Omit<QueryPermissionDto, 'limit' | 'page'>]: QueryPermissionDto[key];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService extends BaseService<
|
||||
PermissionEntity,
|
||||
PermissionRepository,
|
||||
FindParams
|
||||
> {
|
||||
constructor(protected permissionRepository: PermissionRepository) {
|
||||
super(permissionRepository);
|
||||
}
|
||||
|
||||
protected async buildListQuery(
|
||||
queryBuilder: SelectQueryBuilder<PermissionEntity>,
|
||||
options: FindParams,
|
||||
callback?: QueryHook<PermissionEntity>,
|
||||
) {
|
||||
const qb = await super.buildListQB(queryBuilder, options, callback);
|
||||
if (!isNil(options.role)) {
|
||||
qb.andWhere('role.id IN (:...roles)', { roles: [options.role] });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
create(data: PermissionEntity): Promise<PermissionEntity<AbilityTuple, MongoQuery>> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
update(data: PermissionEntity): Promise<PermissionEntity<AbilityTuple, MongoQuery>> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil, omit } from 'lodash';
|
||||
import { In, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseService } from '@/modules/database/base/service';
|
||||
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
|
||||
import { CreateRoleDto, QueryRoleDto, UpdateRoleDto } from '../dtos/role.dtos';
|
||||
import { RoleEntity } from '../entities/role.entity';
|
||||
import { PermissionRepository } from '../repositories/permission.repository';
|
||||
import { RoleRepository } from '../repositories/role.repository';
|
||||
|
||||
@Injectable()
|
||||
export class RoleService extends BaseService<RoleEntity, RoleRepository> {
|
||||
protected enableTrash = true;
|
||||
|
||||
constructor(
|
||||
protected roleRepository: RoleRepository,
|
||||
protected permissionRepository: PermissionRepository,
|
||||
) {
|
||||
super(roleRepository);
|
||||
}
|
||||
|
||||
async create(data: CreateRoleDto): Promise<RoleEntity> {
|
||||
const createRole = {
|
||||
...data,
|
||||
permissions: data.permissions
|
||||
? await this.permissionRepository.findBy({ id: In(data.permissions) })
|
||||
: [],
|
||||
};
|
||||
const item = await this.repository.save(createRole);
|
||||
return this.detail(item.id);
|
||||
}
|
||||
|
||||
async update(data: UpdateRoleDto): Promise<RoleEntity> {
|
||||
const role = await this.detail(data.id);
|
||||
if (data.permissions) {
|
||||
await this.repository
|
||||
.createQueryBuilder('role')
|
||||
.relation(RoleEntity, 'permissions')
|
||||
.of(role)
|
||||
.addAndRemove(data.permissions, role.permissions ?? []);
|
||||
}
|
||||
await this.repository.update(data.id, omit(data, ['id', 'permissions']));
|
||||
return this.detail(data.id);
|
||||
}
|
||||
|
||||
async delete(items: string[], trash = true): Promise<RoleEntity[]> {
|
||||
const roles = await this.repository.find({ where: { id: In(items) }, withDeleted: true });
|
||||
for (const role of roles) {
|
||||
if (role.systemed) {
|
||||
throw new ForbiddenException('can not remove systemed role!');
|
||||
}
|
||||
}
|
||||
|
||||
if (!trash) {
|
||||
return this.repository.remove(roles);
|
||||
}
|
||||
const directs = roles.filter((item) => !isNil(item.deletedAt));
|
||||
const softs = roles.filter((item) => isNil(item.deletedAt));
|
||||
return [
|
||||
...(await this.repository.remove(directs)),
|
||||
...(await this.repository.softRemove(softs)),
|
||||
];
|
||||
}
|
||||
|
||||
protected async buildListQuery(
|
||||
queryBuilder: SelectQueryBuilder<RoleEntity>,
|
||||
options: QueryRoleDto,
|
||||
callback?: QueryHook<RoleEntity>,
|
||||
) {
|
||||
const qb = await super.buildListQB(queryBuilder, options, callback);
|
||||
qb.leftJoinAndSelect(`${this.repository.qbName}.users`, 'users');
|
||||
if (!isNil(options.user)) {
|
||||
qb.andWhere('users.id IN (:...users)', { users: [options.user] });
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './permission.subscriber';
|
||||
export * from './role.subscriber';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
||||
|
||||
import { PermissionEntity } from '../entities/permission.entity';
|
||||
|
||||
export class PermissionSubscriber extends BaseSubscriber<PermissionEntity> {
|
||||
protected entity = PermissionEntity;
|
||||
|
||||
async afterLoad(entity: PermissionEntity): Promise<void> {
|
||||
if (isNil(entity.label)) {
|
||||
entity.label = entity.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isNil } from 'lodash';
|
||||
|
||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
||||
|
||||
import { RoleEntity } from '../entities/role.entity';
|
||||
|
||||
export class RoleSubscriber extends BaseSubscriber<RoleEntity> {
|
||||
protected entity = RoleEntity;
|
||||
|
||||
async afterLoad(entity: RoleEntity): Promise<void> {
|
||||
if (isNil(entity.label)) {
|
||||
entity.label = entity.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { AbilityTuple, MongoAbility, MongoQuery, RawRuleFrom } from '@casl/ability';
|
||||
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { FastifyRequest as Request } from 'fastify';
|
||||
|
||||
import { UserEntity } from '../user/entities';
|
||||
|
||||
import { UserRepository } from '../user/repositories';
|
||||
|
||||
import { PermissionEntity } from './entities/permission.entity';
|
||||
import { RoleEntity } from './entities/role.entity';
|
||||
import { RbacResolver } from './rbac.resolver';
|
||||
|
||||
export type Role = Pick<ClassToPlain<RoleEntity>, 'name' | 'label' | 'description'> & {
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export type PermissionType<P extends AbilityTuple, T extends MongoQuery> = Pick<
|
||||
ClassToPlain<PermissionEntity<P, T>>,
|
||||
'name'
|
||||
> &
|
||||
Partial<Pick<ClassToPlain<PermissionEntity<P, T>>, 'label' | 'description'>> & {
|
||||
rule: Omit<RawRuleFrom<P, T>, 'conditions'> & {
|
||||
conditions?: (user: ClassToPlain<UserEntity>) => RecordAny;
|
||||
};
|
||||
};
|
||||
|
||||
export type PermissionChecker = (
|
||||
ability: MongoAbility,
|
||||
ref?: ModuleRef,
|
||||
request?: Request,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export type CheckerParams = {
|
||||
resolver: RbacResolver;
|
||||
repository: UserRepository;
|
||||
checkers: PermissionChecker[];
|
||||
moduleRef?: ModuleRef;
|
||||
request?: any;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -12,13 +12,30 @@ export function defaultUserConfig(configure: Configure): UserConfig {
|
||||
return {
|
||||
hash: 10,
|
||||
jwt: {
|
||||
token_expired: configure.env.get('USER_TOKEN_EXPIRED', (v) => toNumber(v), 1800),
|
||||
refresh_token_expired: configure.env.get(
|
||||
tokenExpired: configure.env.get('USER_TOKEN_EXPIRED', (v) => toNumber(v), 1800),
|
||||
refreshTokenExpired: configure.env.get(
|
||||
'USER_REFRESH_TOKEN_EXPIRED',
|
||||
(v) => toNumber(v),
|
||||
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': {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,77 @@ export enum UserOrderType {
|
||||
CREATED = 'createdAt',
|
||||
UPDATED = 'updatedAt',
|
||||
}
|
||||
|
||||
export const TokenConst = {
|
||||
USER_TOKEN_SECRET: 'USER_TOKEN_SECRET',
|
||||
DEFAULT_USER_TOKEN_SECRET: 'my-access-secret',
|
||||
USER_REFRESH_TOKEN_EXPIRED: 'USER_REFRESH_TOKEN_EXPIRED',
|
||||
DEFAULT_USER_REFRESH_TOKEN_EXPIRED: 'my-refresh-secret',
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Request,
|
||||
SerializeOptions,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||
|
||||
import { UserIdInterceptor } from '@/modules/user/interceptors';
|
||||
|
||||
import { Guest } from '../decorators/guest.decorator';
|
||||
import { RequestUser } from '../decorators/user.request.decorator';
|
||||
import { UpdateAccountDto, UpdatePasswordDto } from '../dtos/account.dto';
|
||||
import { CredentialDto, RegisterDto } from '../dtos/auth.dto';
|
||||
import { UserEntity } from '../entities/user.entity';
|
||||
import { LocalAuthGuard } from '../guards/local.auth.guard';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { UserService } from '../services/user.service';
|
||||
import { UserModule } from '../user.module';
|
||||
|
||||
@ApiTags('账户操作')
|
||||
@Depends(UserModule)
|
||||
@Controller('account')
|
||||
export class AccountController {
|
||||
constructor(
|
||||
protected authService: AuthService,
|
||||
protected userService: UserService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 使用用户名密码注册用户
|
||||
* @param data
|
||||
*/
|
||||
@Post('register')
|
||||
@Guest()
|
||||
async register(@Body() data: RegisterDto) {
|
||||
return this.authService.register(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录[凭证(可以是用户名,邮箱,手机号等)+密码登录]
|
||||
* @param user
|
||||
* @param _data
|
||||
*/
|
||||
@Post('login')
|
||||
@Guest()
|
||||
@UseGuards(LocalAuthGuard)
|
||||
async login(@RequestUser() user: ClassToPlain<UserEntity>, @Body() _data: CredentialDto) {
|
||||
return { token: await this.authService.createToken(user.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销登录
|
||||
* @param req
|
||||
*/
|
||||
@Post('logout')
|
||||
@ApiBearerAuth()
|
||||
async logout(@Request() req: any) {
|
||||
return this.authService.logout(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户信息[只有用户自己才能查询]
|
||||
* @param user
|
||||
*/
|
||||
@Get('profile')
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async profile(@RequestUser() user: ClassToPlain<UserEntity>) {
|
||||
return this.userService.detail(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更改账户信息
|
||||
* @param user
|
||||
* @param data
|
||||
*/
|
||||
@Patch()
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(UserIdInterceptor)
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async update(
|
||||
@RequestUser() user: ClassToPlain<UserEntity>,
|
||||
@Body()
|
||||
data: UpdateAccountDto,
|
||||
) {
|
||||
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码[必须知道原密码]
|
||||
* @param user
|
||||
* @param data
|
||||
*/
|
||||
@Patch('change-password')
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async changePassword(
|
||||
@RequestUser() user: ClassToPlain<UserEntity>,
|
||||
@Body() data: UpdatePasswordDto,
|
||||
) {
|
||||
return this.authService.changePassword(user, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './account.controller';
|
||||
export * from './user.controller';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||
import { UserService } from '@/modules/user/services';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
|
||||
import { Guest } from '../decorators/guest.decorator';
|
||||
import { FrontedQueryUserDto } from '../dtos/user.dto';
|
||||
|
||||
@ApiTags('用户查询')
|
||||
@Depends(UserModule)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(protected service: UserService) {}
|
||||
|
||||
/**
|
||||
* 用户列表
|
||||
*/
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async list(@Query() options: FrontedQueryUserDto) {
|
||||
return this.service.paginate({ ...options, trashed: SelectTrashMode.NONE });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: IsNull() }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { ALLOW_GUEST } from '@/modules/user/constants';
|
||||
|
||||
export const Guest = () => SetMetadata(ALLOW_GUEST, true);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import { UserEntity } from '../entities/user.entity';
|
||||
|
||||
export const RequestUser = createParamDecorator(async (_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user as ClassToPlain<UserEntity>;
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { OmitType, PickType } from '@nestjs/swagger';
|
||||
|
||||
import { Length } from 'class-validator';
|
||||
|
||||
@@ -6,31 +6,19 @@ import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||
|
||||
import { CaptchaDtoGroups, UserValidateGroup } from '../constants';
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@DtoValidation({ whitelist: false, groups: [UserValidateGroup.ACCOUNT_UPDATE] })
|
||||
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.ACCOUNT_UPDATE], whitelist: false })
|
||||
export class UpdateAccountDto extends PickType(UserCommonDto, ['username', 'nickname']) {}
|
||||
|
||||
/**
|
||||
* 更改用户密码
|
||||
*/
|
||||
@DtoValidation({ groups: [UserValidateGroup.CHANGE_PASSWORD] })
|
||||
export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'plainPassword']) {
|
||||
/**
|
||||
* 待更新的用户ID
|
||||
*/
|
||||
@IsUUID(undefined, { message: '用户ID格式不正确', groups: [UserValidateGroup.USER_UPDATE] })
|
||||
@IsDefined({ groups: ['update'], message: '用户ID必须指定' })
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* 旧密码:用户在更改密码时需要输入的原密码
|
||||
*/
|
||||
@@ -38,3 +26,20 @@ export class UpdatePasswordDto extends PickType(UserCommonDto, ['password', 'pla
|
||||
@Length(8, 50, { message: '密码长度不得少于$constraint1', always: true })
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
@@ -9,6 +9,18 @@ import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||
*/
|
||||
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',
|
||||
'plainPassword',
|
||||
] 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,12 +1,12 @@
|
||||
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 { IsPassword } from '@/modules/core/constraints/password.constraint';
|
||||
import { IsMatchPhone } from '@/modules/core/constraints/phone.number.constraint';
|
||||
import { IsUnique, IsUniqueExist } from '@/modules/database/constraints';
|
||||
import { UserValidateGroup } from '@/modules/user/constants';
|
||||
import { UserEntity } from '@/modules/user/entities/UserEntity';
|
||||
import { CaptchaType, UserValidateGroup } from '@/modules/user/constants';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
|
||||
/**
|
||||
* 用户模块DTO的通用基础字段
|
||||
@@ -38,7 +38,7 @@ export class UserCommonDto {
|
||||
{ entity: UserEntity, ignore: 'id', ignoreKey: 'userId' },
|
||||
{ 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] })
|
||||
username: string;
|
||||
|
||||
@@ -61,7 +61,7 @@ export class UserCommonDto {
|
||||
)
|
||||
@IsMatchPhone(
|
||||
undefined,
|
||||
{ strictMode: trus },
|
||||
{ strictMode: true },
|
||||
{ message: '手机格式错误,示例: +86.15005255555', always: true },
|
||||
)
|
||||
@IsOptional({
|
||||
@@ -111,4 +111,11 @@ export class UserCommonDto {
|
||||
@IsMatch('password', false, { message: '两次输入密码不同', always: true })
|
||||
@IsNotEmpty({ message: '请再次输入密码以确认', always: true })
|
||||
plainPassword: string;
|
||||
|
||||
@IsNumberString(undefined, { message: '验证码必须为数字', always: true })
|
||||
@Length(6, 6, { message: '验证码长度错误', always: true })
|
||||
code!: string;
|
||||
|
||||
@IsEnum(CaptchaType)
|
||||
type: CaptchaType;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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 { 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 { UserOrderType } from '@/modules/user/constants';
|
||||
import { UserOrderType, UserValidateGroup } from '@/modules/user/constants';
|
||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||
|
||||
/**
|
||||
@@ -17,7 +21,39 @@ export class CreateUserDto extends PickType(UserCommonDto, [
|
||||
'email',
|
||||
'password',
|
||||
'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数据验证
|
||||
*/
|
||||
@DtoValidation({ type: 'query', skipMissingProperties: true })
|
||||
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)
|
||||
@IsOptional()
|
||||
orderBy?: UserOrderType;
|
||||
|
||||
/**
|
||||
* 过滤激活状态
|
||||
*/
|
||||
@Transform(({ value }) => toBoolean(value))
|
||||
@IsBoolean()
|
||||
actived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端查询用户
|
||||
*/
|
||||
@DtoValidation({ type: 'query' })
|
||||
export class FrontedQueryUserDto extends OmitType(QueryUserDto, ['trashed']) {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user