Compare commits
23
Commits
9d23757f45
...
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 |
@@ -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
+20
-12
@@ -1,11 +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: [
|
||||
{
|
||||
@@ -14,21 +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操作' },
|
||||
...userApi.tags.app,
|
||||
...contentApi.tags.manager,
|
||||
...rbacApi.tags.manager,
|
||||
...userApi.tags.manager,
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'app.content',
|
||||
path: 'content',
|
||||
controllers: Object.values(contentControllers),
|
||||
},
|
||||
...userApi.routes.app,
|
||||
...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 { IsDefined, IsUUID } 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']) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { CategoryEntity, CommentEntity, PostEntity, TagEntity } from '@/modules/content/entities';
|
||||
import { PermissionAction, SystemRoles } from '@/modules/rbac/constants';
|
||||
import { PermissionEntity, RoleEntity } from '@/modules/rbac/entities';
|
||||
import { RbacResolver } from '@/modules/rbac/rbac.resolver';
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
|
||||
@Injectable()
|
||||
export class ContentRbac implements OnModuleInit {
|
||||
constructor(private ref: ModuleRef) {}
|
||||
onModuleInit() {
|
||||
const resolver = this.ref.get(RbacResolver, { strict: false });
|
||||
resolver.addPermissions([
|
||||
{
|
||||
name: 'post.create',
|
||||
rule: {
|
||||
action: PermissionAction.CREATE,
|
||||
subject: PostEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'post.owner',
|
||||
rule: {
|
||||
action: PermissionAction.OWNER,
|
||||
subject: PostEntity,
|
||||
conditions: (user) => ({
|
||||
'author.id': user.id,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'comment.create',
|
||||
rule: {
|
||||
action: PermissionAction.CREATE,
|
||||
subject: CommentEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'comment.owner',
|
||||
rule: {
|
||||
action: PermissionAction.OWNER,
|
||||
subject: CommentEntity,
|
||||
conditions: (user) => ({
|
||||
'author.id': user.id,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'post.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: PostEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tag.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: TagEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'category.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: CategoryEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'comment.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: CommentEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'permission.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: PermissionEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'role.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: RoleEntity,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user.manage',
|
||||
rule: {
|
||||
action: PermissionAction.MANAGE,
|
||||
subject: UserEntity,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
resolver.addRoles([
|
||||
{
|
||||
name: SystemRoles.USER,
|
||||
permissions: [
|
||||
'post.read',
|
||||
'post.create',
|
||||
'post.owner',
|
||||
'comment.create',
|
||||
'comment.owner',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'content-manage',
|
||||
label: '内容管理员',
|
||||
description: '管理内容模块',
|
||||
permissions: ['post.manage', 'category.manage', 'tag.manage', 'comment.manage'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ export class PostRepository extends BaseRepository<PostEntity> {
|
||||
return this.createQueryBuilder(this.qbName)
|
||||
.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 };
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +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';
|
||||
@@ -131,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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -19,6 +19,23 @@ export function defaultUserConfig(configure: Configure): UserConfig {
|
||||
3600 * 30,
|
||||
),
|
||||
},
|
||||
captcha: {
|
||||
sms: {
|
||||
login: {
|
||||
template: configure.env.get('SMS_LOGIN_CAPTCHA_CLOUD', 'your-id'),
|
||||
},
|
||||
register: {
|
||||
template: configure.env.get('SMS_REGISTER_CAPTCHA_CLOUD', 'your-id'),
|
||||
},
|
||||
'retrieve-password': {
|
||||
template: configure.env.get('SMS_RETRIEVE_PASSWORD_CAPTCHA_CLOUD', 'your-id'),
|
||||
},
|
||||
},
|
||||
email: {
|
||||
register: {},
|
||||
'retrieve-password': {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,3 +40,68 @@ export const TokenConst = {
|
||||
};
|
||||
|
||||
export const ALLOW_GUEST = 'allowGuest';
|
||||
|
||||
/**
|
||||
* 验证码发送数据DTO验证组
|
||||
*/
|
||||
export enum CaptchaDtoGroups {
|
||||
// 发送短信登录验证码
|
||||
PHONE_LOGIN = 'phone-login',
|
||||
// 发送邮件登录验证码
|
||||
EMAIL_LOGIN = 'email-login',
|
||||
// 发送短信注册验证码
|
||||
PHONE_REGISTER = 'phone-register',
|
||||
// 发送邮件注册验证码
|
||||
EMAIL_REGISTER = 'email-register',
|
||||
// 发送找回密码的短信验证码
|
||||
PHONE_RETRIEVE_PASSWORD = 'phone-retrieve-password',
|
||||
// 发送找回密码的邮件验证码
|
||||
EMAIL_RETRIEVE_PASSWORD = 'email-retrieve-password',
|
||||
// 发送登录用户密码重置的短信验证码
|
||||
PHONE_RESET_PASSWORD = 'phone-reset-password',
|
||||
// 发送登录用户密码重置的邮件验证码
|
||||
EMAIL_RESET_PASSWORD = 'email-reset-password',
|
||||
// 发送手机号绑定或更改的短信验证码
|
||||
BOUND_PHONE = 'bound-phone',
|
||||
// 发送邮箱地址绑定或更改的邮件验证码
|
||||
BOUND_EMAIL = 'bound-email',
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码操作类别
|
||||
*/
|
||||
export enum CaptchaActionType {
|
||||
// 登录操作
|
||||
LOGIN = 'login',
|
||||
// 注册操作
|
||||
REGISTER = 'register',
|
||||
// 找回密码操作
|
||||
RETRIEVE_PASSWORD = 'retrieve-password',
|
||||
// 重置密码操作
|
||||
RESET_PASSWORD = 'reset-password',
|
||||
// 手机号或邮箱地址绑定操作
|
||||
ACCOUNT_BOUND = 'account-bound',
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码类型
|
||||
*/
|
||||
export enum CaptchaType {
|
||||
SMS = 'sms',
|
||||
EMAIL = 'email',
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码异步列队名称
|
||||
*/
|
||||
export const SEND_CAPTCHA_QUEUE = 'send-captcha-queue';
|
||||
|
||||
/**
|
||||
* 发送短信验证码任务处理名称
|
||||
*/
|
||||
export const SMS_CAPTCHA_JOB = 'sms-captcha-job';
|
||||
|
||||
/**
|
||||
* 发送邮件验证码任务处理名称
|
||||
*/
|
||||
export const EMAIL_CAPTCHA_JOB = 'mail-captcha-job';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Request,
|
||||
SerializeOptions,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
@@ -14,6 +15,8 @@ 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';
|
||||
@@ -83,8 +86,13 @@ export class AccountController {
|
||||
*/
|
||||
@Patch()
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(UserIdInterceptor)
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async update(@RequestUser() user: ClassToPlain<UserEntity>, @Body() data: UpdateAccountDto) {
|
||||
async update(
|
||||
@RequestUser() user: ClassToPlain<UserEntity>,
|
||||
@Body()
|
||||
data: UpdateAccountDto,
|
||||
) {
|
||||
return this.userService.update({ id: user.id, ...pick(data, ['username', 'nickname']) });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './user.controller';
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
||||
import { PermissionAction } from '@/modules/rbac/constants';
|
||||
import { Permission } from '@/modules/rbac/decorators/permission.decorator';
|
||||
import { PermissionChecker } from '@/modules/rbac/types';
|
||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||
import { RequestUser } from '@/modules/user/decorators/user.request.decorator';
|
||||
import { CreateUserDto, FrontedQueryUserDto, UpdateUserDto } from '@/modules/user/dtos/user.dto';
|
||||
import { UserEntity } from '@/modules/user/entities';
|
||||
import { UserService } from '@/modules/user/services';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
|
||||
const permission: PermissionChecker = async (ab) =>
|
||||
ab.can(PermissionAction.MANAGE, UserEntity.name);
|
||||
|
||||
@ApiTags('用户管理')
|
||||
@Depends(UserModule)
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(protected service: UserService) {}
|
||||
|
||||
/**
|
||||
* 用户列表
|
||||
*/
|
||||
@Get()
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async list(@Query() options: FrontedQueryUserDto) {
|
||||
return this.service.paginate(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.detail(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增用户
|
||||
* @param data
|
||||
*/
|
||||
@Post()
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async store(@Body() data: CreateUserDto) {
|
||||
return this.service.create(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param data
|
||||
*/
|
||||
@Patch()
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async update(@Body() data: UpdateUserDto) {
|
||||
return this.service.update(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除用户
|
||||
* @param user
|
||||
* @param data
|
||||
*/
|
||||
@Delete()
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async delete(@RequestUser() user: UserEntity, @Body() data: DeleteWithTrashDto) {
|
||||
const { ids, trash } = data;
|
||||
if (ids.includes(user.id)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return this.service.delete(ids, trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复用户
|
||||
* @param data
|
||||
*/
|
||||
@Patch('restore')
|
||||
@Permission(permission)
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async restore(@Body() data: RestoreDto) {
|
||||
const { ids } = data;
|
||||
return this.service.restore(ids);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,18 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
SerializeOptions,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query, SerializeOptions } from '@nestjs/common';
|
||||
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { DeleteWithTrashDto, RestoreDto } from '@/modules/content/dtos/delete.with.trash.dto';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
import { SelectTrashMode } from '@/modules/database/constants';
|
||||
import { Depends } from '@/modules/restful/decorators/depend.decorator';
|
||||
import { UserService } from '@/modules/user/services';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
|
||||
import { Guest } from '../decorators/guest.decorator';
|
||||
import { CreateUserDto, UpdateUserDto } from '../dtos/user.dto';
|
||||
import { UserService } from '../services/user.service';
|
||||
import { FrontedQueryUserDto } from '../dtos/user.dto';
|
||||
|
||||
@ApiTags('用户管理')
|
||||
@ApiTags('用户查询')
|
||||
@Depends(UserModule)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
@@ -32,8 +24,8 @@ export class UserController {
|
||||
@Get()
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async list() {
|
||||
return this.service.list();
|
||||
async list(@Query() options: FrontedQueryUserDto) {
|
||||
return this.service.paginate({ ...options, trashed: SelectTrashMode.NONE });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,52 +36,6 @@ export class UserController {
|
||||
@Guest()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async detail(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.service.detail(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增用户
|
||||
* @param data
|
||||
*/
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async store(@Body() data: CreateUserDto) {
|
||||
return this.service.create(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param data
|
||||
*/
|
||||
@Patch()
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-detail'] })
|
||||
async update(@Body() data: UpdateUserDto) {
|
||||
return this.service.update(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除用户
|
||||
* @param data
|
||||
*/
|
||||
@Delete()
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async delete(@Body() data: DeleteWithTrashDto) {
|
||||
const { ids, trash } = data;
|
||||
return this.service.delete(ids, trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量恢复用户
|
||||
* @param data
|
||||
*/
|
||||
@Patch('restore')
|
||||
@ApiBearerAuth()
|
||||
@SerializeOptions({ groups: ['user-list'] })
|
||||
async restore(@Body() data: RestoreDto) {
|
||||
const { ids } = data;
|
||||
return this.service.restore(ids);
|
||||
return this.service.detail(id, async (qb) => qb.andWhere({ deletedAt: IsNull() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { PickType } from '@nestjs/swagger';
|
||||
import { OmitType, PickType } from '@nestjs/swagger';
|
||||
|
||||
import { IsDefined, IsUUID, Length } from 'class-validator';
|
||||
import { Length } from 'class-validator';
|
||||
|
||||
import { IsPassword } from '@/modules/core/constraints/password.constraint';
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { UserCommonDto } from '@/modules/user/dtos/user.common.dto';
|
||||
|
||||
import { UserValidateGroup } from '../constants';
|
||||
import { CaptchaDtoGroups, UserValidateGroup } from '../constants';
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
@DtoValidation({ 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;
|
||||
|
||||
/**
|
||||
* 旧密码:用户在更改密码时需要输入的原密码
|
||||
*/
|
||||
@@ -40,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,11 +1,11 @@
|
||||
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 { CaptchaType, UserValidateGroup } from '@/modules/user/constants';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
|
||||
/**
|
||||
@@ -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;
|
||||
|
||||
@@ -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,8 +1,12 @@
|
||||
import { PartialType, PickType } from '@nestjs/swagger';
|
||||
import { OmitType, PartialType, PickType } from '@nestjs/swagger';
|
||||
|
||||
import { IsDefined, IsEnum, IsUUID } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDefined, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
|
||||
import { 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, 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']) {}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
|
||||
|
||||
@Entity('user_captcha')
|
||||
export class CaptchaEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ comment: '验证码' })
|
||||
code!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CaptchaActionType,
|
||||
comment: '验证操作类型',
|
||||
})
|
||||
action!: CaptchaActionType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CaptchaType,
|
||||
comment: '验证码类型',
|
||||
})
|
||||
type!: CaptchaType;
|
||||
|
||||
@Column({ comment: '手机号/邮箱地址' })
|
||||
value!: string;
|
||||
|
||||
@CreateDateColumn({
|
||||
comment: '创建时间',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
comment: '更新时间',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
OneToMany,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { CommentEntity, PostEntity } from '@/modules/content/entities';
|
||||
import { PermissionEntity } from '@/modules/rbac/entities/permission.entity';
|
||||
import { RoleEntity } from '@/modules/rbac/entities/role.entity';
|
||||
import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity';
|
||||
|
||||
/**
|
||||
@@ -60,6 +63,9 @@ export class UserEntity {
|
||||
@Column({ comment: '用户邮箱', length: 256, nullable: true, unique: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ comment: '用户状态,是否激活', default: false })
|
||||
actived?: boolean;
|
||||
|
||||
/**
|
||||
* 用户创建时间
|
||||
*/
|
||||
@@ -101,4 +107,21 @@ export class UserEntity {
|
||||
*/
|
||||
@OneToMany(() => AccessTokenEntity, (token) => token.user, { cascade: true })
|
||||
accessTokens: Relation<AccessTokenEntity>[];
|
||||
|
||||
/**
|
||||
* 用户权限
|
||||
*/
|
||||
@Expose()
|
||||
@ManyToMany(() => PermissionEntity, (permission) => permission.users, {
|
||||
cascade: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
permissions: Relation<PermissionEntity>[];
|
||||
|
||||
/**
|
||||
* 用户角色
|
||||
*/
|
||||
@Expose()
|
||||
@ManyToMany(() => RoleEntity, (role) => role.users, { cascade: true, onDelete: 'CASCADE' })
|
||||
roles: Relation<RoleEntity>[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseRepository } from '@/modules/database/base/repository';
|
||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||
import { AccessTokenEntity } from '@/modules/user/entities';
|
||||
|
||||
@CustomRepository(AccessTokenEntity)
|
||||
export class AccessTokenRepository extends BaseRepository<AccessTokenEntity> {
|
||||
protected _qbName: string = 'accessToken';
|
||||
|
||||
buildBaseQB(): SelectQueryBuilder<AccessTokenEntity> {
|
||||
return super.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './user.repository';
|
||||
export * from './access.token.repository';
|
||||
export * from './refresh.token.repository';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BaseRepository } from '@/modules/database/base/repository';
|
||||
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
|
||||
import { RefreshTokenEntity } from '@/modules/user/entities';
|
||||
|
||||
@CustomRepository(RefreshTokenEntity)
|
||||
export class RefreshTokenRepository extends BaseRepository<RefreshTokenEntity> {
|
||||
protected _qbName: string = 'refreshToken';
|
||||
|
||||
buildBaseQB(): SelectQueryBuilder<RefreshTokenEntity> {
|
||||
return super.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ export class UserRepository extends BaseRepository<UserEntity> {
|
||||
protected _qbName: string = 'user';
|
||||
|
||||
buildBaseQuery() {
|
||||
return this.createQueryBuilder(this.qbName).orderBy(`${this.qbName}.createdAt`, 'DESC');
|
||||
return this.createQueryBuilder(this.qbName)
|
||||
.orderBy(`${this.qbName}.createdAt`, 'DESC')
|
||||
.leftJoinAndSelect(`${this.qbName}.roles`, 'roles')
|
||||
.leftJoinAndSelect(`${this.qbName}.permissions`, 'permissions');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RouteOption, TagOption } from '../restful/types';
|
||||
|
||||
import * as controllers from './controllers';
|
||||
import * as managerControllers from './controllers/manager';
|
||||
|
||||
export function createUserApi() {
|
||||
const routes: Record<'app', RouteOption[]> = {
|
||||
const routes: Record<'app' | 'manager', RouteOption[]> = {
|
||||
app: [
|
||||
{
|
||||
name: 'app.user',
|
||||
@@ -11,13 +12,18 @@ export function createUserApi() {
|
||||
controllers: Object.values(controllers),
|
||||
},
|
||||
],
|
||||
manager: [
|
||||
{
|
||||
name: 'app.user',
|
||||
path: 'manager',
|
||||
controllers: Object.values(managerControllers),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tags: Record<'app', (string | TagOption)[]> = {
|
||||
app: [
|
||||
{ name: '用户管理', description: '对用户进行CRUD操作' },
|
||||
{ name: '账户操作', description: '注册登录、查看修改账户信息、修改密码等' },
|
||||
],
|
||||
const tags: Record<'app' | 'manager', (string | TagOption)[]> = {
|
||||
app: [{ name: '账户操作', description: '注册登录、查看修改账户信息、修改密码等' }],
|
||||
manager: [{ name: '用户管理', description: '管理用户信息' }],
|
||||
};
|
||||
|
||||
return { routes, tags };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { FastifyRequest as Request } from 'fastify';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { JwtModule, JwtModuleOptions, JwtService } from '@nestjs/jwt';
|
||||
@@ -14,6 +13,7 @@ import { defaultUserConfig, getUserConfig } from '@/modules/user/config';
|
||||
import { AccessTokenEntity } from '@/modules/user/entities/access.token.entity';
|
||||
import { RefreshTokenEntity } from '@/modules/user/entities/refresh.token.entity';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
import { AccessTokenRepository, RefreshTokenRepository } from '@/modules/user/repositories';
|
||||
import { JwtConfig, JwtPayload, UserConfig } from '@/modules/user/types';
|
||||
|
||||
import { TokenConst } from '../constants';
|
||||
@@ -26,6 +26,8 @@ export class TokenService {
|
||||
constructor(
|
||||
protected configure: Configure,
|
||||
protected jwtService: JwtService,
|
||||
private accessTokenRepository: AccessTokenRepository,
|
||||
private refreshTokenRepository: RefreshTokenRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,7 +43,7 @@ export class TokenService {
|
||||
return null;
|
||||
}
|
||||
const token = await this.generateAccessToken(user, now);
|
||||
await accessToken.remove();
|
||||
await this.accessTokenRepository.remove(accessToken);
|
||||
response.header('token', token.accessToken.value);
|
||||
return token;
|
||||
}
|
||||
@@ -65,7 +67,8 @@ export class TokenService {
|
||||
accessToken.value = signed;
|
||||
accessToken.user = user;
|
||||
accessToken.expiredAt = now.add(config.tokenExpired, 'second').toDate();
|
||||
await accessToken.save();
|
||||
|
||||
await this.accessTokenRepository.save(accessToken);
|
||||
const refreshToken = await this.generateRefreshToken(
|
||||
accessToken,
|
||||
await getTime(this.configure),
|
||||
@@ -94,7 +97,7 @@ export class TokenService {
|
||||
);
|
||||
refreshToken.expiredAt = now.add(config.refreshTokenExpired, 'second').toDate();
|
||||
refreshToken.accessToken = accessToken;
|
||||
await refreshToken.save();
|
||||
await this.refreshTokenRepository.save(refreshToken);
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
@@ -103,7 +106,10 @@ export class TokenService {
|
||||
* @param value
|
||||
*/
|
||||
async checkAccessToken(value: string) {
|
||||
return AccessTokenEntity.findOne({ where: { value }, relations: ['user', 'refreshToken'] });
|
||||
return this.accessTokenRepository.findOne({
|
||||
where: { value },
|
||||
relations: ['user', 'refreshToken'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,9 +117,9 @@ export class TokenService {
|
||||
* @param value
|
||||
*/
|
||||
async removeAccessToken(value: string) {
|
||||
const accessToken = await AccessTokenEntity.findOne({ where: { value } });
|
||||
const accessToken = await this.accessTokenRepository.findOne({ where: { value } });
|
||||
if (accessToken) {
|
||||
await accessToken.remove();
|
||||
await this.accessTokenRepository.remove(accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,15 +128,15 @@ export class TokenService {
|
||||
* @param value
|
||||
*/
|
||||
async removeRefreshToken(value: string) {
|
||||
const refreshToken = await RefreshTokenEntity.findOne({
|
||||
const refreshToken = await this.refreshTokenRepository.findOne({
|
||||
where: { value },
|
||||
relations: ['accessToken'],
|
||||
});
|
||||
if (refreshToken) {
|
||||
if (refreshToken.accessToken) {
|
||||
await refreshToken.accessToken.remove();
|
||||
await this.accessTokenRepository.remove(refreshToken.accessToken);
|
||||
}
|
||||
await refreshToken.remove();
|
||||
await this.refreshTokenRepository.remove(refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNil } from 'lodash';
|
||||
import { isArray, isNil } from 'lodash';
|
||||
import { DataSource, EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Configure } from '@/modules/config/configure';
|
||||
@@ -8,6 +8,8 @@ import { BaseService } from '@/modules/database/base/service';
|
||||
|
||||
import { QueryHook } from '@/modules/database/types';
|
||||
|
||||
import { SystemRoles } from '@/modules/rbac/constants';
|
||||
import { RoleRepository } from '@/modules/rbac/repositories';
|
||||
import { UserRepository } from '@/modules/user/repositories';
|
||||
|
||||
import { CreateUserDto, QueryUserDto, UpdateUserDto } from '../dtos/user.dto';
|
||||
@@ -21,26 +23,68 @@ export class UserService extends BaseService<UserEntity, UserRepository> {
|
||||
protected configure: Configure,
|
||||
protected dataSource: DataSource,
|
||||
protected userRepository: UserRepository,
|
||||
protected roleRepository: RoleRepository,
|
||||
) {
|
||||
super(userRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @param roles
|
||||
* @param permissions
|
||||
* @param data
|
||||
*/
|
||||
async create(data: CreateUserDto): Promise<UserEntity> {
|
||||
async create({ roles, permissions, ...data }: CreateUserDto): Promise<UserEntity> {
|
||||
const user = await this.userRepository.save(data, { reload: true });
|
||||
if (isArray(roles) && roles.length > 0) {
|
||||
await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.relation('roles')
|
||||
.of(user)
|
||||
.add(roles);
|
||||
}
|
||||
if (isArray(permissions) && permissions.length > 0) {
|
||||
await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.relation('permissions')
|
||||
.of(user)
|
||||
.add(permissions);
|
||||
}
|
||||
await this.addUserRole(await this.detail(user.id));
|
||||
return this.detail(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param roles
|
||||
* @param permissions
|
||||
* @param data
|
||||
*/
|
||||
async update(data: UpdateUserDto): Promise<UserEntity> {
|
||||
async update({ roles, permissions, ...data }: UpdateUserDto): Promise<UserEntity> {
|
||||
const updated = await this.userRepository.save(data, { reload: true });
|
||||
return this.detail(updated.id);
|
||||
const user = await this.detail(updated.id);
|
||||
if (
|
||||
(isNil(roles) || roles.length <= 0) &&
|
||||
(isNil(permissions) || permissions.length <= 0)
|
||||
) {
|
||||
return user;
|
||||
}
|
||||
if (isArray(roles) && roles.length > 0) {
|
||||
await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.relation('roles')
|
||||
.of(user)
|
||||
.addAndRemove(roles, user.roles ?? []);
|
||||
}
|
||||
if (isArray(permissions) && permissions.length > 0) {
|
||||
await this.userRepository
|
||||
.createQueryBuilder('user')
|
||||
.relation('permissions')
|
||||
.of(user)
|
||||
.addAndRemove(permissions, user.permissions ?? []);
|
||||
}
|
||||
await this.addUserRole(await this.detail(user.id));
|
||||
return this.detail(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,9 +131,32 @@ export class UserService extends BaseService<UserEntity, UserRepository> {
|
||||
) {
|
||||
const { orderBy } = options;
|
||||
const qb = await super.buildListQB(queryBuilder, options, callback);
|
||||
if (!isNil(options.role)) {
|
||||
qb.andWhere('roles.id IN (:...roles)', { roles: [options.role] });
|
||||
}
|
||||
if (!isNil(options.permission)) {
|
||||
qb.andWhere('permissions.id IN (:...permissions)', {
|
||||
permissions: [options.permission],
|
||||
});
|
||||
}
|
||||
if (!isNil(orderBy)) {
|
||||
qb.orderBy(`${this.repository.qbName}.${orderBy}`, 'ASC');
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
protected async addUserRole(user: UserEntity) {
|
||||
const roleRelation = this.userRepository.createQueryBuilder().relation('roles').of(user);
|
||||
const roleNames = (user.roles ?? []).map((role) => role.name);
|
||||
const noneUserRole = roleNames.length <= 0 || !roleNames.includes(SystemRoles.USER);
|
||||
if (noneUserRole) {
|
||||
const userRole = await this.roleRepository.findOne({
|
||||
relations: ['users'],
|
||||
where: { name: SystemRoles.USER },
|
||||
});
|
||||
if (!isNil(userRole)) {
|
||||
await roleRelation.add(userRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
|
||||
import { EventSubscriber, InsertEvent, LoadEvent, UpdateEvent } from 'typeorm';
|
||||
|
||||
import { BaseSubscriber } from '@/modules/database/base/subscriber';
|
||||
import { RoleEntity } from '@/modules/rbac/entities';
|
||||
import { UserEntity } from '@/modules/user/entities/user.entity';
|
||||
import { encrypt } from '@/modules/user/utils';
|
||||
|
||||
@@ -36,4 +37,21 @@ export class UserSubscriber extends BaseSubscriber<UserEntity> {
|
||||
event.entity.password = await encrypt(this.configure, event.entity.password);
|
||||
}
|
||||
}
|
||||
|
||||
async afterLoad(user: UserEntity, event: LoadEvent<any>): Promise<void> {
|
||||
let permissions = user.permissions ?? [];
|
||||
for (const role of user.roles ?? []) {
|
||||
const roleEntity = await this.getManage(event).findOneOrFail(RoleEntity, {
|
||||
relations: ['permissions'],
|
||||
where: { id: role.id },
|
||||
});
|
||||
permissions = [...permissions, ...(roleEntity.permissions ?? [])];
|
||||
}
|
||||
user.permissions = permissions.reduce((o, n) => {
|
||||
if (o.find(({ name }) => name === n.name)) {
|
||||
return o;
|
||||
}
|
||||
return [...o, n];
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CaptchaActionType, CaptchaType } from '@/modules/user/constants';
|
||||
import { CaptchaEntity } from '@/modules/user/entities/captcha.entity';
|
||||
|
||||
/**
|
||||
* 用户配置类型
|
||||
* 自定义用户模块配置
|
||||
*/
|
||||
export interface UserConfig {
|
||||
/**
|
||||
@@ -10,6 +13,8 @@ export interface UserConfig {
|
||||
* jwt token的生成配置
|
||||
*/
|
||||
jwt: JwtConfig;
|
||||
|
||||
captcha?: CustomCaptchaConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,3 +44,76 @@ export interface JwtPayload {
|
||||
*/
|
||||
iat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认用户模块配置
|
||||
*/
|
||||
export interface DefaultUserConfig {
|
||||
hash: number;
|
||||
jwt: Pick<Required<JwtConfig>, 'tokenExpired' | 'refreshTokenExpired'>;
|
||||
captcha: DefaultCaptchaConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义验证码配置
|
||||
*/
|
||||
export interface CustomCaptchaConfig {
|
||||
[CaptchaType.SMS]?: {
|
||||
[key in CaptchaActionType]?: Partial<SmsCaptchaOption>;
|
||||
};
|
||||
[CaptchaType.EMAIL]?: {
|
||||
[key in CaptchaActionType]?: Partial<EmailCaptchaOption>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认验证码配置
|
||||
*/
|
||||
export interface DefaultCaptchaConfig {
|
||||
[CaptchaType.SMS]: {
|
||||
[key in CaptchaActionType]: CaptchaOption;
|
||||
};
|
||||
[CaptchaType.EMAIL]: {
|
||||
[key in CaptchaActionType]: Omit<EmailCaptchaOption, 'template'>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用验证码选项
|
||||
*/
|
||||
export interface CaptchaOption {
|
||||
limit: number; // 验证码发送间隔时间
|
||||
expired: number; // 验证码有效时间
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机验证码选项
|
||||
*/
|
||||
export interface SmsCaptchaOption extends CaptchaOption {
|
||||
template: string; // 云厂商短信推送模板ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件验证码选项
|
||||
*/
|
||||
export interface EmailCaptchaOption extends CaptchaOption {
|
||||
subject: string; // 邮件主题
|
||||
template?: string; // 模板路径
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务传给消费者的数据类型
|
||||
*/
|
||||
export interface SendCaptchaQueueJob {
|
||||
captcha: { [key in keyof CaptchaEntity]: CaptchaEntity[key] };
|
||||
option: SmsCaptchaOption | EmailCaptchaOption;
|
||||
otherVars?: RecordAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码正确性验证
|
||||
*/
|
||||
export type CaptchaValidate<T extends RecordAny = RecordNever> = T & {
|
||||
value: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { DynamicModule, forwardRef, Module } from '@nestjs/common';
|
||||
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Configure } from '@/modules/config/configure';
|
||||
import { DatabaseModule } from '@/modules/database/database.module';
|
||||
import { addEntities, addSubscribers } from '@/modules/database/utils';
|
||||
|
||||
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||
|
||||
import * as entities from './entities';
|
||||
import * as guards from './guards';
|
||||
import * as interceptors from './interceptors';
|
||||
@@ -22,6 +24,7 @@ export class UserModule {
|
||||
module: UserModule,
|
||||
imports: [
|
||||
PassportModule,
|
||||
forwardRef(() => RbacModule),
|
||||
services.TokenService.JwtModuleFactory(configure),
|
||||
await addEntities(configure, Object.values(entities)),
|
||||
DatabaseModule.forRepository(Object.values(repositories)),
|
||||
|
||||
@@ -21,3 +21,10 @@ export async function encrypt(configure: Configure, password: string) {
|
||||
export function decrypt(password: string, hashed: string) {
|
||||
return bcrypt.compareSync(password, hashed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机验证码
|
||||
*/
|
||||
export function generateCaptchaCode() {
|
||||
return Math.random().toFixed(6).slice(-6);
|
||||
}
|
||||
|
||||
+68
-7
@@ -5,17 +5,25 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
|
||||
import { existsSync } from 'fs-extra';
|
||||
import { isNil } from 'lodash';
|
||||
import { isArray, isNil, omit } from 'lodash';
|
||||
|
||||
import { JwtAuthGuard } from '@/modules/user/guards';
|
||||
import { RbacModule } from '@/modules/rbac/rbac.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
|
||||
import * as configs from './config';
|
||||
import { ContentModule } from './modules/content/content.module';
|
||||
import { CreateOptions } from './modules/core/types';
|
||||
import { GlobalExceptionFilter } from './modules/core/filters/global-exception.filter';
|
||||
import {
|
||||
BullOptions,
|
||||
CreateOptions,
|
||||
QueueOptions,
|
||||
RedisOption,
|
||||
RedisOptions,
|
||||
} from './modules/core/types';
|
||||
import * as dbCommands from './modules/database/commands';
|
||||
import { DatabaseModule } from './modules/database/database.module';
|
||||
import { MeiliModule } from './modules/meilisearch/meili.module';
|
||||
import { RbacGuard } from './modules/rbac/guards/rbac.guard';
|
||||
import { Restful } from './modules/restful/restful';
|
||||
import { RestfulModule } from './modules/restful/restful.module';
|
||||
import { ApiConfig } from './modules/restful/types';
|
||||
@@ -29,8 +37,12 @@ export const createOptions: CreateOptions = {
|
||||
await RestfulModule.forRoot(configure),
|
||||
await ContentModule.forRoot(configure),
|
||||
await UserModule.forRoot(configure),
|
||||
await RbacModule.forRoot(configure),
|
||||
],
|
||||
globals: { guard: JwtAuthGuard },
|
||||
globals: {
|
||||
guard: RbacGuard,
|
||||
filter: GlobalExceptionFilter,
|
||||
},
|
||||
builder: async ({ configure, BootModule }) => {
|
||||
const container = await NestFactory.create<NestFastifyApplication>(
|
||||
BootModule,
|
||||
@@ -46,12 +58,61 @@ export const createOptions: CreateOptions = {
|
||||
if (existsSync(join(__dirname, 'metadata.js'))) {
|
||||
metadata = (await import(join(__dirname, 'metadata.js'))).default;
|
||||
}
|
||||
if (existsSync(join(__dirname, 'metadata.ts'))) {
|
||||
metadata = (await import(join(__dirname, 'metadata.ts'))).default;
|
||||
}
|
||||
await restful.factoryDocs(container, metadata);
|
||||
}
|
||||
|
||||
return container;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成Redis配置
|
||||
* @param options
|
||||
*/
|
||||
export const createRedisOptions = (options: RedisOptions): RedisOption[] | undefined => {
|
||||
if (isNil(options)) {
|
||||
return undefined;
|
||||
}
|
||||
const config: Array<RedisOption> = Array.isArray(options)
|
||||
? options
|
||||
: [{ ...options, name: 'default' }];
|
||||
if (config.length < 1) {
|
||||
return undefined;
|
||||
}
|
||||
if (isNil(config.find(({ name }) => name === 'default'))) {
|
||||
config[0].name = 'default';
|
||||
}
|
||||
|
||||
return config.reduce<RedisOption[]>((o, n) => {
|
||||
const names = o.map(({ name }) => name) as string[];
|
||||
return names.includes(n.name) ? o : [...o, n];
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成BullMQ模块的配置
|
||||
* @param options
|
||||
* @param redis
|
||||
*/
|
||||
export const createQueueOptions = (
|
||||
options: QueueOptions,
|
||||
redis: Array<RedisOption>,
|
||||
): BullOptions | undefined => {
|
||||
if (isNil(options) || isNil(redis)) {
|
||||
return undefined;
|
||||
}
|
||||
const names = redis.map(({ name }) => name);
|
||||
if (names.length < 1 || !names.includes('default')) {
|
||||
return undefined;
|
||||
}
|
||||
if (isArray(options)) {
|
||||
return options.map((option) => ({
|
||||
...omit(option, 'redis'),
|
||||
connection: redis.find(({ name: c }) => c === (option.redis ?? 'default')),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
...omit(options, 'redis'),
|
||||
connection: redis.find(({ name: c }) => c === (options.redis ?? 'default')),
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user