Compare commits

...
114 Commits
Author SHA1 Message Date
shuijiangui fac8be4dd0 add mq and redis 2025-08-01 23:08:40 +08:00
shuijiangui de01ffd7c7 add mq and redis 2025-08-01 10:50:03 +08:00
shuijiangui 91b5cce39b add mq and redis 2025-08-01 10:49:50 +08:00
shuijiangui ef6380395c add mq and redis 2025-08-01 10:49:23 +08:00
shuijiangui 24cb8e9843 add mq and redis 2025-07-04 16:24:32 +08:00
shuijiangui 730b77448e modify bugs 2025-07-04 10:26:05 +08:00
shuijiangui 0d11d2bd8a add bullmq and redis 2025-07-03 22:26:18 +08:00
shuijiangui 769973517b add test case 2025-07-02 22:37:06 +08:00
shuijiangui d2692e6a11 modify bugs 2025-07-02 22:35:23 +08:00
shuijiangui 0f8abaec7f add rbac module 2025-07-01 15:33:31 +08:00
shuijiangui 18b3f7b9da add rbac module 2025-07-01 12:39:20 +08:00
shuijiangui 16a08bf280 add rbac module 2025-07-01 10:38:38 +08:00
shuijiangui 6951ea1373 add rbac module 2025-06-30 21:51:17 +08:00
shuijiangui 2c70ce4cdf add rbac module 2025-06-30 17:43:12 +08:00
shuijiangui db5b553a93 add rbac module 2025-06-30 13:44:11 +08:00
shuijiangui 9461fc53f3 add rbac module 2025-06-30 13:12:21 +08:00
shuijiangui fab90132b0 add rbac module 2025-06-29 23:17:15 +08:00
shuijiangui bff2c4a4c7 add rbac module 2025-06-29 22:07:51 +08:00
shuijiangui 23c8eba867 add rbac module 2025-06-29 20:07:05 +08:00
shuijiangui 5de299c0c1 add rbac module 2025-06-26 15:51:03 +08:00
shuijiangui 99cb459fb7 add rbac module 2025-06-26 15:25:01 +08:00
shuijiangui 46aa8312e7 add rbac module 2025-06-25 17:45:15 +08:00
shuijiangui bbc9c5d6bf add rbac module 2025-06-25 17:44:48 +08:00
shuijiangui 9d23757f45 add user module and jwt 2025-06-23 21:21:47 +08:00
shuijiangui ddb436cb9a add user module and jwt 2025-06-23 08:41:02 +08:00
shuijiangui e67711c2fe add user module and jwt 2025-06-23 08:35:24 +08:00
shuijiangui afe44ab68a add user module and jwt 2025-06-23 08:31:26 +08:00
shuijiangui d7d9777d1a add user module and jwt 2025-06-23 08:23:20 +08:00
shuijiangui 5c9ecec8fc add user module and jwt 2025-06-23 07:36:58 +08:00
shuijiangui ac80ecaf4b add user module and jwt 2025-06-22 23:00:33 +08:00
shuijiangui 85b2062a2a add user module and jwt 2025-06-22 19:16:23 +08:00
shuijiangui 1e043718b4 add user module and jwt 2025-06-22 17:57:19 +08:00
shuijiangui b8cc65a768 add user module and jwt 2025-06-22 17:12:45 +08:00
shuijiangui 6fa6e1f076 add user and jwt module 2025-06-22 14:49:54 +08:00
shuijiangui 46253b3a4f add user module 2025-06-22 14:27:15 +08:00
shuijiangui 040b0b71d5 add user module 2025-06-22 13:10:49 +08:00
shuijiangui 92cfd94093 add user module 2025-06-22 10:30:50 +08:00
shuijiangui ee5af33a96 add user module 2025-06-22 09:39:15 +08:00
shuijiangui 1f8d4dc413 add user module 2025-06-22 08:59:23 +08:00
shuijiangui 89d4a7c78f add user module 2025-06-22 08:43:48 +08:00
shuijiangui 9865f294e9 add user module 2025-06-21 23:30:27 +08:00
shuijiangui 6a894b2266 add db seed handler 2025-06-21 19:40:04 +08:00
shuijiangui b20b02eb2a add db seed handler 2025-06-21 17:07:09 +08:00
shuijiangui af46a76a9c add db seed handler 2025-06-21 15:05:39 +08:00
shuijiangui b9ac3e5f94 add db seed handler 2025-06-21 14:54:07 +08:00
shuijiangui 0191343d10 add db migration 2025-06-21 08:21:21 +08:00
shuijiangui f7d85d3ba4 add db migration 2025-06-20 23:01:54 +08:00
shuijiangui 94fc1a6877 add db migration 2025-06-20 23:01:08 +08:00
shuijiangui edff100ef2 add db migration 2025-06-20 22:46:11 +08:00
shuijiangui cecc96bbba add db migration 2025-06-20 22:07:56 +08:00
shuijiangui 77e27a4b93 add db migration 2025-06-20 18:59:42 +08:00
shuijiangui 64fcacdb3d add db migration 2025-06-20 14:38:22 +08:00
shuijiangui 13b5bc629a add db migration 2025-06-20 11:06:39 +08:00
shuijiangui 67e4466687 add yargs bun and pm2 2025-06-19 22:10:48 +08:00
shuijiangui dbb090f93c add yargs bun and pm2 2025-06-19 17:42:00 +08:00
shuijiangui 806cfa0ff6 add yargs bun and pm2 2025-06-17 22:36:38 +08:00
shuijiangui 2190ea3066 add yargs bun and pm2 2025-06-17 15:58:22 +08:00
shuijiangui 1ca32341fa add yargs 2025-06-16 23:29:28 +08:00
shuijiangui b30f9bf7d1 add yargs 2025-06-16 23:12:40 +08:00
shuijiangui d8c1adf08b add swagger 2025-06-16 23:01:20 +08:00
shuijiangui b6bc983f08 add swagger 2025-06-16 19:26:40 +08:00
shuijiangui 03e70436c6 update test case 2025-06-15 15:48:51 +08:00
shuijiangui 2fe89029c3 pnpm update 2025-06-15 14:14:03 +08:00
shuijiangui 8f42803d63 pnpm update 2025-06-15 14:13:01 +08:00
shuijiangui 73e5a897c6 add swagger 2025-06-14 23:32:15 +08:00
shuijiangui 5a5306b10d add swagger 2025-06-14 21:43:29 +08:00
shuijiangui 35cc963ca8 add route module 2025-06-14 20:58:41 +08:00
shuijiangui a5b7a9bd5d add route module 2025-06-14 20:57:47 +08:00
shuijiangui fc9b8f7f2a add route module 2025-06-13 23:24:31 +08:00
shuijiangui c74757d692 add route module 2025-06-12 23:45:42 +08:00
shuijiangui 1057041738 add route module 2025-06-12 23:32:23 +08:00
shuijiangui 92b93e2e89 add config module 2025-06-12 18:12:21 +08:00
shuijiangui cb15a976f1 add config module 2025-06-10 23:08:01 +08:00
shuijiangui 2e4997da9c add config module 2025-06-09 14:59:22 +08:00
shuijiangui 2afa6bcb4c add config module 2025-06-08 14:27:50 +08:00
shuijiangui e5912600ce add config module 2025-06-08 14:26:04 +08:00
shuijiangui 88ba3f5a16 add config module 2025-06-06 13:39:02 +08:00
shuijiangui b2189a8c5f add config module 2025-06-05 22:04:45 +08:00
shuijiangui 7b6a5ca24e add config module 2025-06-05 12:45:03 +08:00
shuijiangui a92ad374e1 add base subscriber 2025-06-04 22:50:04 +08:00
shuijiangui 4991b83641 add base subscriber 2025-06-04 16:14:13 +08:00
shuijiangui 38208a57e8 add base subscriber 2025-06-04 16:13:25 +08:00
shuijiangui 3fa9ecc33a add base service 2025-06-04 11:14:19 +08:00
shuijiangui 6f581ad383 add base service 2025-06-03 23:11:36 +08:00
shuijiangui 3f0a9ca6fb add base repository 2025-06-03 14:29:58 +08:00
shuijiangui 2c76c94578 add base repository 2025-06-03 13:09:31 +08:00
shuijiangui 9f88f9c731 add base repository 2025-06-03 13:08:26 +08:00
shuijiangui 68b06801d3 add base repository 2025-06-02 19:21:24 +08:00
shuijiangui 823046e94a add base repository 2025-06-02 18:52:53 +08:00
shuijiangui 5452f890ec add meili search 2025-06-01 22:02:53 +08:00
shuijiangui 02ccf58457 add meili search 2025-06-01 21:24:43 +08:00
shuijiangui 7af6efc642 add meili search 2025-06-01 20:58:45 +08:00
shuijiangui 996f887d73 add meili search 2025-06-01 18:46:13 +08:00
shuijiangui fdd9d80310 modify PostService and ContentModule 2025-06-01 14:57:20 +08:00
shuijiangui 37f11a0097 add content db type 2025-06-01 09:39:59 +08:00
shuijiangui 87aba1d0eb tag chang to patch delete 2025-05-31 19:02:53 +08:00
shuijiangui b7bb509b70 post and soft delete 2025-05-31 18:46:12 +08:00
shuijiangui 427997f1cb modify delete comment 2025-05-31 12:05:57 +08:00
shuijiangui 50dbb06b29 add test case 2025-05-31 09:30:32 +08:00
shuijiangui 3fe801d448 add test case 2025-05-31 09:15:24 +08:00
shuijiangui 88447f0db6 add test case 2025-05-30 22:11:40 +08:00
shuijiangui e26cb841fc add test case 2025-05-29 23:15:43 +08:00
shuijiangui c283df576b add test case 2025-05-29 14:59:00 +08:00
shuijiangui a75a27e627 add constraint 2025-05-29 11:07:25 +08:00
shuijiangui 05160509ec add constraint 2025-05-28 22:58:00 +08:00
shuijiangui 43d34250e2 add constraint 2025-05-27 23:29:14 +08:00
shuijiangui e0d2f7652c add constraint 2025-05-27 23:09:49 +08:00
shuijiangui a08964bd4a add constraint 2025-05-27 22:01:28 +08:00
shuijiangui e71f08a3de add test case 2025-05-27 21:46:00 +08:00
shuijiangui 4350232069 add test case 2025-05-27 19:55:14 +08:00
shuijiangui f5cf346dd1 add test case 2025-05-27 18:52:17 +08:00
shuijiangui a6dcfce339 add test case 2025-05-27 14:07:54 +08:00
shuijiangui 1cfeb1f22e add test case 2025-05-26 00:01:56 +08:00
shuijiangui 8eb9126bc2 add test case 2025-05-25 23:06:12 +08:00
248 changed files with 29646 additions and 3035 deletions
+16 -16
View File
@@ -1,17 +1,17 @@
module.exports = {
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"proseWrap": "never",
"endOfLine": "auto",
"semi": true,
"tabWidth": 4,
"overrides": [
{
"files": ".prettierrc",
"options": {
"parser": "json"
}
}
]
}
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
proseWrap: 'never',
endOfLine: 'auto',
semi: true,
tabWidth: 4,
overrides: [
{
files: '.prettierrc',
options: {
parser: 'json',
},
},
],
};
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": []
}
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
[
'@babel/plugin-transform-runtime',
{
regenerator: true,
},
],
],
};
+2914
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# APP_NAME=nestapp
# APP_HOST=127.0.0.1
# APP_PORT=3000
# APP_SSL=false
# APP_TIMEZONE=Asia/Shanghai
# APP_LOCALE=zh_CN
# APP_FALLBACK_LOCALE=en
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_USERNAME=3r
# DB_PASSWORD=12345678
# DB_NAME=3r
+198
View File
@@ -0,0 +1,198 @@
/* eslint-disable import/no-unresolved */
/* eslint-disable @typescript-eslint/no-require-imports */
/* eslint-disable import/no-extraneous-dependencies */
const { FlatCompat } = require('@eslint/eslintrc');
const js = require('@eslint/js');
const typescriptEslint = require('@typescript-eslint/eslint-plugin');
const tsParser = require('@typescript-eslint/parser');
const { defineConfig, globalIgnores } = require('eslint/config');
const jest = require('eslint-plugin-jest');
const prettier = require('eslint-plugin-prettier');
const unusedImports = require('eslint-plugin-unused-imports');
const globals = require('globals');
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
module.exports = defineConfig([
{
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
globals: {
...globals.node,
...globals.jest,
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
jest,
prettier,
'unused-imports': unusedImports,
},
extends: compat.extends(
'airbnb-base',
'airbnb-typescript/base',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:jest/recommended',
'prettier',
'plugin:prettier/recommended',
),
rules: {
'no-console': 0,
'no-var-requires': 0,
'no-restricted-syntax': 0,
'no-continue': 0,
'no-await-in-loop': 0,
'no-return-await': 0,
'no-unused-vars': 0,
'no-multi-assign': 0,
'no-param-reassign': [
2,
{
props: false,
},
],
'import/prefer-default-export': 0,
'import/no-cycle': 0,
'import/no-dynamic-require': 0,
'max-classes-per-file': 0,
'class-methods-use-this': 0,
'guard-for-in': 0,
'no-underscore-dangle': 0,
'no-plusplus': 0,
'no-lonely-if': 0,
'no-bitwise': [
'error',
{
allow: ['~'],
},
],
'import/no-absolute-path': 0,
'import/extensions': 0,
'import/no-named-default': 0,
'no-restricted-exports': 0,
'import/no-extraneous-dependencies': [
1,
{
devDependencies: [
'**/*.test.{ts,js}',
'**/*.spec.{ts,js}',
'./test/**.{ts,js}',
'./scripts/**/*.{ts,js}',
],
},
],
'import/order': [
1,
{
pathGroups: [
{
pattern: '@/**',
group: 'external',
position: 'after',
},
],
alphabetize: {
order: 'asc',
caseInsensitive: false,
},
'newlines-between': 'always-and-inside-groups',
warnOnUnassignedImports: true,
},
],
'unused-imports/no-unused-imports': 1,
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'none',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-unused-vars': 0,
'@typescript-eslint/no-empty-interface': 0,
'@typescript-eslint/no-this-alias': 0,
'@typescript-eslint/no-var-requires': 0,
'@typescript-eslint/no-use-before-define': 0,
'@typescript-eslint/explicit-member-accessibility': 0,
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/no-unnecessary-type-assertion': 0,
'@typescript-eslint/require-await': 0,
'@typescript-eslint/no-for-in-array': 0,
'@typescript-eslint/interface-name-prefix': 0,
'@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/no-floating-promises': 0,
'@typescript-eslint/restrict-template-expressions': 0,
'@typescript-eslint/no-unsafe-assignment': 0,
'@typescript-eslint/no-unsafe-return': 0,
'@typescript-eslint/no-unused-expressions': 0,
'@typescript-eslint/no-misused-promises': 0,
'@typescript-eslint/no-unsafe-member-access': 0,
'@typescript-eslint/no-unsafe-call': 0,
'@typescript-eslint/no-unsafe-argument': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/lines-between-class-members': 0,
'@typescript-eslint/no-throw-literal': 0,
},
settings: {
extensions: ['.ts', '.d.ts', '.cts', '.mts', '.js', '.cjs', 'mjs', '.json'],
},
},
globalIgnores([
'**/dist',
'**/back',
'**/node_modules',
'**/pnpm-lock.yaml',
'**/docker',
'**/Dockerfile*',
'**/LICENSE',
'**/yarn-error.log',
'**/.history',
'**/.vscode',
'**/.docusaurus',
'**/.dockerignore',
'**/.DS_Store',
'**/.eslintignore',
'**/.editorconfig',
'**/.gitignore',
'**/.prettierignore',
'**/.eslintcache',
'**/*.lock',
'**/*.svg',
'**/*.md',
'**/*.ejs',
'**/*.html',
'**/*.png',
'**/*.toml',
]),
]);
+14 -1
View File
@@ -3,8 +3,21 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"assets": [
"assets/**/*"
],
"watchAssets": true,
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
"typeCheck": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true,
"controllerKeyOfComment": "summary"
}
}
]
}
}
+91 -40
View File
@@ -6,13 +6,15 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"cli": "bun --bun src/console/bin.ts",
"dev": "cross-env NODE_ENV=development pnpm cli start -w",
"prebuild": "rimraf dist",
"build": "nest build",
"build": "cross-env NODE_ENV=production nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start": "cross-env NODE_ENV=development nest start",
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -21,54 +23,99 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.0.3",
"@nestjs/core": "^10.0.3",
"@nestjs/platform-fastify": "^10.0.3",
"@nestjs/swagger": "^7.4.2",
"@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",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"better-sqlite3": "^11.10.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.56.0",
"chalk": "^5.4.1",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"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",
"reflect-metadata": "^0.1.13",
"rimraf": "^5.0.1",
"rxjs": "^7.8.1",
"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",
"passport-local": "^1.0.0",
"pm2": "^6.0.8",
"reflect-metadata": "^0.2.2",
"rimraf": "^6.0.1",
"rxjs": "^7.8.2",
"sanitize-html": "^2.17.0",
"tencentcloud-sdk-nodejs": "^4.1.67",
"typeorm": "^0.3.24",
"validator": "^13.15.0"
"uuid": "^11.1.0",
"validator": "^13.15.15",
"yaml": "^2.8.0",
"yargs": "^18.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.3",
"@nestjs/schematics": "^10.0.1",
"@nestjs/testing": "^10.0.3",
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.66",
"@types/jest": "29.5.2",
"@types/lodash": "^4.17.16",
"@types/node": "^20.3.1",
"@eslint/compat": "^1.3.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.29.0",
"@faker-js/faker": "^9.8.0",
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.3",
"@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",
"@types/supertest": "^2.0.12",
"@types/supertest": "^6.0.3",
"@types/uuid": "^10.0.0",
"@types/validator": "^13.15.1",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"eslint": "^8.43.0",
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.34.0",
"@typescript-eslint/parser": "^8.34.0",
"bun-types": "^1.2.16",
"cross-env": "^7.0.3",
"eslint": "^9.29.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"jest": "29.5.0",
"prettier": "^2.8.8",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.13.5",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^16.2.0",
"jest": "30.0.0",
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"supertest": "^7.1.1",
"ts-jest": "29.4.0",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "~5.1.3"
"typescript": "~5.8.3"
},
"jest": {
"moduleFileExtensions": [
@@ -83,12 +130,16 @@
"<rootDir>/test/**/*.test.ts"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(js|jsx)$": "ts-jest",
"^.+\\.(ts|tsx)?$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"transformIgnorePatterns": [
"node_modules/(?!(chalk))"
],
"testEnvironment": "node"
},
"pnpm": {
+5814 -2401
View File
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { AppInterceptor } from '@/modules/core/providers/app.interceptor';
import { database } from './config';
import { DEFAULT_VALIDATION_CONFIG } from './modules/content/constants';
import { ContentModule } from './modules/content/content.module';
import { CoreModule } from './modules/core/core.module';
import { AppFilter } from './modules/core/providers/app.filter';
import { AppPipe } from './modules/core/providers/app.pipe';
import { DatabaseModule } from './modules/database/database.module';
@Module({
imports: [ContentModule, CoreModule.forRoot(), DatabaseModule.forRoot(database)],
providers: [
{
provide: APP_PIPE,
useValue: new AppPipe(DEFAULT_VALIDATION_CONFIG),
},
{
provide: APP_INTERCEPTOR,
useClass: AppInterceptor,
},
{
provide: APP_FILTER,
useClass: AppFilter,
},
],
})
export class AppModule {}
+64
View File
@@ -0,0 +1,64 @@
# 什么是 Docker
**Docker** 最初是 `dotCloud` 公司创始人 [Solomon Hykes](https://github.com/shykes) 在法国期间发起的一个公司内部项目,它是基于 `dotCloud` 公司多年云服务技术的一次革新,并于 [2013 年 3 月以 Apache 2.0 授权协议开源][docker-soft],主要项目代码在 [GitHub](https://github.com/moby/moby) 上进行维护。`Docker` 项目后来还加入了 Linux 基金会,并成立推动 [开放容器联盟(OCI](https://www.opencontainers.org/)。
**Docker** 自开源后受到广泛的关注和讨论,至今其 [GitHub 项目](https://github.com/moby/moby) 已经超过 5 万 4 千个星标和一万多个 `fork`。甚至由于 `Docker` 项目的火爆,在 `2013` 年底,[dotCloud 公司决定改名为 Docker](https://blog.docker.com/2013/10/dotcloud-is-becoming-docker-inc/)。`Docker` 最初是在 `Ubuntu 12.04` 上开发实现的;`Red Hat` 则从 `RHEL 6.5` 开始对 `Docker` 进行支持;`Google` 也在其 `PaaS` 产品中广泛应用 `Docker`
**Docker** 使用 `Google` 公司推出的 [Go 语言](https://golang.org/) 进行开发实现,基于 `Linux` 内核的 [cgroup](https://zh.wikipedia.org/wiki/Cgroups)[namespace](https://en.wikipedia.org/wiki/Linux_namespaces),以及 [AUFS](https://en.wikipedia.org/wiki/Aufs) 类的 [Union FS](https://en.wikipedia.org/wiki/Union_mount) 等技术,对进程进行封装隔离,属于 [操作系统层面的虚拟化技术](https://en.wikipedia.org/wiki/Operating-system-level_virtualization)。由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。最初实现是基于 [LXC](https://linuxcontainers.org/lxc/introduction/),从 0.7 版本以后开始去除 `LXC`,转而使用自行开发的 [libcontainer](https://github.com/docker/libcontainer),从 1.11 开始,则进一步演进为使用 [runC](https://github.com/opencontainers/runc) 和 [containerd](https://github.com/containerd/containerd)。
![Docker 架构](https://docs.microsoft.com/en-us/virtualization/windowscontainers/deploy-containers/media/docker-on-linux.png)
> `runc` 是一个 Linux 命令行工具,用于根据 [OCI容器运行时规范](https://github.com/opencontainers/runtime-spec) 创建和运行容器。
> `containerd` 是一个守护程序,它管理容器生命周期,提供了在一个节点上执行容器和管理镜像的最小功能集。
**Docker** 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护。使得 `Docker` 技术比虚拟机技术更为轻便、快捷。
下面的图片比较了 **Docker** 和传统虚拟化方式的不同之处。传统虚拟机技术是虚拟出一套硬件后,在其上运行一个完整操作系统,在该系统上再运行所需应用进程;而容器内的应用进程直接运行于宿主的内核,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。
![传统虚拟化](_images/virtualization.png)
![Docker](_images/docker.png)
[docker-soft]:https://en.wikipedia.org/wiki/Docker_(software)
# 为什么要使用 Docker
作为一种新兴的虚拟化方式,`Docker` 跟传统的虚拟化方式相比具有众多的优势。
## 更高效的利用系统资源
由于容器不需要进行硬件虚拟以及运行完整操作系统等额外开销,`Docker` 对系统资源的利用率更高。无论是应用执行速度、内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的主机,往往可以运行更多数量的应用。
## 更快速的启动时间
传统的虚拟机技术启动应用服务往往需要数分钟,而 `Docker` 容器应用,由于直接运行于宿主内核,无需启动完整的操作系统,因此可以做到秒级、甚至毫秒级的启动时间。大大的节约了开发、测试、部署的时间。
## 一致的运行环境
开发过程中一个常见的问题是环境一致性问题。由于开发环境、测试环境、生产环境不一致,导致有些 bug 并未在开发过程中被发现。而 `Docker` 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 *「这段代码在我机器上没问题啊」* 这类问题。
## 持续交付和部署
对开发和运维([DevOps](https://zh.wikipedia.org/wiki/DevOps))人员来说,最希望的就是一次创建或配置,可以在任意地方正常运行。
使用 `Docker` 可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员可以通过 [Dockerfile](../image/dockerfile/) 来进行镜像构建,并结合 [持续集成(Continuous Integration)](https://en.wikipedia.org/wiki/Continuous_integration) 系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合 [持续部署(Continuous Delivery/Deployment)](https://en.wikipedia.org/wiki/Continuous_delivery) 系统进行自动部署。
而且使用 [`Dockerfile`](../image/build.md) 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。
## 更轻松的迁移
由于 `Docker` 确保了执行环境的一致性,使得应用的迁移更加容易。`Docker` 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云,甚至是笔记本,其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。
## 更轻松的维护和扩展
`Docker` 使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得非常简单。此外,`Docker` 团队同各个开源项目团队一起维护了一大批高质量的 [官方镜像](https://hub.docker.com/search/?type=image&image_filter=official),既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务的镜像制作成本。
## 对比传统虚拟机总结
| 特性 | 容器 | 虚拟机 |
| :--------- | :----------------- | :---------- |
| 启动 | 秒级 | 分钟级 |
| 硬盘使用 | 一般为 `MB` | 一般为 `GB` |
| 性能 | 接近原生 | 弱于 |
| 系统支持量 | 单机支持上千个容器 | 一般几十个 |
+197
View File
@@ -0,0 +1,197 @@
## 概念
下面简单介绍一下,进程,线程,锁,协程,通道,并发,并行的概念。
### 进程
一个程序一般会开一个进程(像phpfpm这种是开多进程的),一个进程可以开N多线程。
### 线程
线程在单核计算机上是抢占执行的,也就是说同一时间单核计算机上一个进程内只有一个线程列队(线程池),但可以有多条线程在线程池,它们等待时机执行,但是同一时间段运行着的只有一个线程。这样做的好处就是异步执行,比如一些耗时的任务可以先放入线程池,等其余的线程不忙碌的时候再拿出来执行。而多核计算机则可以有多个线程池,把不同的线程池分配到不同的核心上,但是同样的一个核心上的每个线程池同时只有一个线程在运行,所以多核心上同时运行的线程数量就等于核心的数量。
### 协程
> 这里的协程直接使用goroutine概念,跟coroutine不相干
每个线程又可以有非常多的协程,协程在线程上成列队排序,就跟每个内核的线程池一样,并且随机执行,当某个线程上一个协程在执行过程中卡死的时候go会自动把此线程上其余协程分配到空闲的线程上执行。
### 通道与锁
一个线程池的线程列队是随机执行的,这会导致共享的数据无法确定。比如一个线程列队共享一个数据`count`,但是其中一部分线程设置`count`值,另一部分线程又需要读取`count`值,而我们又无法确定哪些线程先执行哪些后执行,这时候在一个设置`count`的线程里会导致`count`值被随意覆盖,而读取的线程里无法获得准确值。所以我们就需要**线程锁**来控制线程的执行顺序,以及锁定共享数据了,通过这种**共享内存**的方法虽然解决了问题,但也造成了不必要的风险,比如锁住后忘记解锁会导致整个程序崩溃。因为一个线程上的协程也是随机抢占(就是不自觉排队,看能力插队)执行的,这就导致了与线程同样的问题-数据不准确。
go直接放弃操作线程,只操作协程,并且使用简单易用的通道来解决这个问题。通道的目标就是在一部分协程间建立起一座数据通信的桥梁,使各个协程可以关联,从而通过执行顺序来控制共享数据。
### 并发并行
多个任务在同一时间片执行是为并行,多个任务在同一时间异步抢占执行是为并发。由于一个核心同时只能有一个线程运行,一个线程同时只有一个协程处于运行状态,所以协程的并行需要线程的并行,线程的并行需要多核心。所以go并行的首要条件就是多核心计算机。其中并行的每个核心其实包含了并发的协程。但是重要的是,对于单核计算机来说,协程也远比线程轻量,协程的切换和控制更加方便,所以即使单核计算机上goroutine的性能也是无可比拟的。
### 线程设置
Go默认设置的线程数等于执行go程序的计算机的核心数,但是这个数字是可以读取也可以修改的。如下:
> 当GOMAXPROCS方法没有参数或参数<=0时为读取线程数
```go
// 获取核心数
fmt.Println(runtime.NumCPU())
// 读取当前线程数
fmt.Println(runtime.GOMAXPROCS(0))
// 设置线程数
runtime.GOMAXPROCS(16)
```
### 协程优势
类似PHP(swoole等除外)这种语言是最传统的,每次执行一个单位需要过一遍整个进程。而像Java,Node(新版本),Python等编程语言对并发的处理一般使用多线程的方式解决,而一个线程是非常重量的单位,一个进程开的线程数是非常有限的,开多了很容易造成程序假死。而go协程(区别于python,kotlin等coroutines)是非常轻量的单位,一个进程就可以管理上百万个协程,所以这就使得go的并发量特别高,进一步是go程序的执行性能非常优秀。协程主要用于API请求,消息推送,聊天会话等,每次请求开一个协程,开个几十万都不是问题。
## 协程
### 初步
> go的入口函数`main`是所有协程的父协程
Go语言里使用协程非常简单,只要在调用一个函数的时候在其前面加上`go`关键字即可。比如:
```go
package main
import (
"time"
"fmt"
)
func outputName(name string) {
fmt.Println(name)
}
func main() {
go outputName("Jack")
go outputName("Rose")
go outputName("Lily")
// 保证其它的协程执行完毕后(1秒的时间足够执行三次输出)再继续执行主协程
time.Sleep(time.Second)
fmt.Println("do in main goroutine")
}
```
结果:
> 每次执行输出的结果都是不一样的除了最后一条,因为输出人名的是放到线程里异步抢占执行的协程
```shell
Jack
Lily
Rose
do in main goroutine
```
协程也可以使用立即执行的*匿名函数*,与JS里的用法类似,比如:
> 立即执行的意思,是立即加入到协程列队,而不是同步当场执行
```go
func main() {
go func() {
fmt.Println("Jack")
}()
// 保证其它的协程执行完毕后(1秒的时间足够执行三次输出)再继续执行主协程
time.Sleep(time.Second)
fmt.Println("do in main goroutine")
}
```
为了不会让因子协程执行错误而导致主协程甚至整个程序挂掉,可以在有风险的子协程函数内加入一个`defer`关键字的异常处理,并在异常处理的时候使用`recover()`函数恢复异常状态为正常状态,这样可以确保在子协程执行遇到错误的时候立即结束这个协程而不至于冒泡到主协程
```go
func outputName(name string) {
defer func() {
if err := recover(); err != nil {
// log error
}
}()
}
```
### 通道
通道(channels)是协程间的数据通信机制。可以用通道对协程进行执行顺序和数据的控制。
#### 创建通道
通道是引用类型数据,使用`make`关键字创建,如下:
```go
ch1 := make(chan 类型,缓冲数量) // 类型可以是int,string等基础类型,也可以是结构体指针或接口等引用类型
```
#### 无缓冲通道
无缓冲通道就是创建通道的时候把`缓冲数量`参数的值设置为`<=0`的数值或者不设置。一个无缓冲通道要求发送goroutine和接受gorouine同时就绪才能执行。如果发送者没有准备好,则接受者会一直处于阻塞状态,反之亦然。
> 阻塞是某个协程处于等待数据的状态而无法向下执行
```go
package main
import (
"fmt"
"math/rand"
"time"
)
var groups = []string{
"jack", "rose", "Lily", "jobs", "tim", "jordan",
}
func init() {
// 设置一个取货随机数的基准为unix时间戳
rand.Seed(time.Now().UnixNano())
}
func main() {
// 设置一个判断是否全歼敌军的通道变量
alldied := make(chan bool)
go func() {
for {
// 随机判断是否击杀
killed := rand.Intn(2) != 0
if !killed {
fmt.Println("本轮没有击杀敌人")
} else {
// 如果击杀则pop最后一个敌人
groups = groups[0 : len(groups)-1]
fmt.Printf("击杀1人,剩余%d个敌人\n", len(groups))
}
// 全部歼灭则发送通道值通知父routine并跳出循环
if len(groups) <= 0 {
alldied <- true
break
}
// 每轮射击后休息1秒
time.Sleep(time.Second)
}
}()
// 阻塞到全部击杀
<-alldied
fmt.Println("敌人全部被歼灭")
}
```
执行结果如下
```shell
本轮没有击杀敌人
击杀1人,剩余5个敌人
击杀1人,剩余4个敌人
本轮没有击杀敌人
击杀1人,剩余3个敌人
本轮没有击杀敌人
击杀1人,剩余2个敌人
击杀1人,剩余1个敌人
本轮没有击杀敌人
击杀1人,剩余0个敌人
敌人全部被歼灭
```
#### 缓冲通道
+221
View File
@@ -0,0 +1,221 @@
## 关于
将大型代码库分成单独的独立版本的packages对于代码共享非常有用。但是,跨许多仓库进行更改很麻烦且难以跟踪,并且跨仓库的测试变得非常复杂。
为了解决这些(以及许多其他)问题,一些项目会将其代码库组织到单个的多packages仓库中(有时称为[monorepos](https://github.com/babel/babel/blob/master/doc/design/monorepo.md))。比如像BabelReactAngularEmberMeteorJest等以及许多其他项目都选择把所有代码包放在一个仓库中进行开发。
Lerna就是一种用于优化使用git和npm管理多应用包仓库工作流程的工具。
Lerna还可以减少开发和构建环境中复制大量的重复npm依赖包的时间和空间,这通常是将项目分成许多单独的NPM依赖包的缺点。有关详细信息,请参见 [hoist documentation](https://github.com/lerna/lerna/blob/master/doc/hoist.md)。
### Lerna项目的结构是怎样的?
实际上非常轻量。您的文件结构如下所示:
```shell
my-lerna-repo/
package.json
packages/
package-1/
package.json
package-2/
package.json
```
### Lerna可以做什么?
`bootstrap`将连接仓库中的依赖。`publish`用于发布任何有更新的包。
### Lerna不能做什么?
Lerna并不是 serverless monorepos的部署工具。Hoisting可能与传统的serverless monorepo部署技术不兼容。
## 入门
> 以下说明适用于Lerna3.x。对于新的Lerna项目,建议使用它而不是2.x。
首先,使用[npm](https://www.npmjs.com/)将Lerna安装为项目的开发依赖。
```shell
$ mkdir lerna-repo && cd $_
$ npx lerna init
```
这将创建一个`lerna.json`配置文件以及一个`packages`文件夹,因此您的文件夹现在应如下所示:
```shell
lerna-repo/
packages/
package.json
lerna.json
```
## 如何运行
Lerna允许您使用以下两种模式之一来管理项目:固定或独立。
### 固定/锁定模式(默认)
> 译者注:这种模式适用于多个目录模块属于同一个仓库的情形,比如一个React/Vue组件库
固定模式的Lerna项目的所有包在单个版本库上操作。版本号通过项目根目录下`lerna.json`文件中`version`字段配置。当您运行时`lerna publish`,如果某个模块自上次发布以来已被更新,它将被更新为您要发布的新版本。这意味着您仅在需要时才发布packages的新版本。
这是[Babel](https://github.com/babel/babel)当前使用的模式。如果要自动将所有packages版本捆绑在一起,请使用此选项。这种方法的一个问题是,对任何packages进行重大更改都会导致所有packages都具有新的主要版本。
### 独立模式
> 译者注:这种模式比较适用于一个包含前后端以及多端同构的应用,比如koa+react admin+taro小程序...
```
lerna init --independent
```
独立模式Lerna项目允许维护者彼此独立地增加packages的版本。每次发布时,都会提示您已更改的每个packages,以指定是补丁,次要,主要还是自定义更改。
独立模式使您可以更具体地更新每个packages的版本,并使一组组件变得有意义。将该模式与[语义版本](https://github.com/semantic-release/semantic-release)类的东西相结合来解决痛点。(这个任务已经在[atlassian / lerna-semantic-release](https://github.com/atlassian/lerna-semantic-release)这个仓库上完成)。
> 将`lerna.json`中的`version`字段设置为`independent`,以确保独立模式运行。
## 故障排除
如果您在使用Lerna时遇到任何问题,请查阅我们的[故障排除](https://github.com/lerna/lerna/blob/master/doc/troubleshooting.md) 文档,在这里您可以找到问题的答案。
## 经常问的问题
见 [FAQ.md](https://github.com/lerna/lerna/blob/master/FAQ.md).
## 概念
当Lerna 在运行命令遇到错误时,它将会记录到`lerna-debug.log`文件(与`npm-debug.log`类似)。
Lerna还支持 [scoped packages](https://docs.npmjs.com/misc/scope)。
运行`lerna --help`以查看所有可用的命令和选项。
### lerna.json
```json
{
"version": "1.1.3",
"npmClient": "npm",
"command": {
"publish": {
"ignoreChanges": ["ignored-file", "*.md"],
"message": "chore(release): publish",
"registry": "https://npm.pkg.github.com"
},
"bootstrap": {
"ignore": "component-*",
"npmClientArgs": ["--no-package-lock"]
}
},
"packages": ["packages/*"]
}
```
- `version` 当前仓库的版本。
- `npmClient`:指定运行命令的客户端 (也可以为每个命令指定). 如果想使用yarn来运行所有命令则改为 `"yarn"` 。默认为 "npm".
- `command.publish.ignoreChanges`:不会包含在 `lerna changed/publish`命令中的globs数组。使用此功能可以防止不必要的更改发布在新版本中,例如修正`README.md`错字。
- `command.publish.message`:发布更新版本时的自定义提交消息。有关更多详细信息,请参见[@ lerna / version](https://github.com/lerna/lerna/blob/master/commands/version#--message-msg)。
- `command.publish.registry`:使用它来设置要发布到的自定义registry url来替代默认的npmjs.org,如果需要身份认证,请确保您能通过认证。
- `command.bootstrap.ignore`:运行`lerna bootstrap`命令时不会被引导的glob数组。
- `command.bootstrap.npmClientArgs`:在运行 `lerna bootstrap`的时候,将会被作为参数直接传递给`npm install`的字符串数组。
- `command.bootstrap.scope`:指定在运行`lerna bootstrap`命令时将会引导的packages的globs数组。
- `packages`: 存放package位置的globs数组。
`lerna.json`中的packages配置是匹配到包含`package.json`文件的目录的globs列表,这个就是lerna识别“leaf” packages的方式(注意:根目录下的`package.json`用于管理整个仓库的dev依赖和脚本)。
默认情况下,lerna将packages列表初始化为`["packages/*"]`,但是您也可以使用其他目录,例如`["modules/*"]``["package1", "package2"]`。定义好的globs变量是相对于`lerna.json`文件所在目录的,该目录`lerna.json`通常是仓库的根目录。唯一的限制是您不能直接嵌套packages位置,但这也是“正常的” npm packages所共有的一个限制。
例如,`["packages/*", "src/**"]`匹配文件树:
```shell
packages/
├── foo-pkg
│ └── package.json
├── bar-pkg
│ └── package.json
├── baz-pkg
│ └── package.json
└── qux-pkg
└── package.json
src/
├── admin
│ ├── my-app
│ │ └── package.json
│ ├── stuff
│ │ └── package.json
│ └── things
│ └── package.json
├── profile
│ └── more-things
│ └── package.json
├── property
│ ├── more-stuff
│ │ └── package.json
│ └── other-things
│ └── package.json
└── upload
└── other-stuff
└── package.json
```
将leaf packages放在`packages/*`下被认为是“最佳实践”,但不是使用Lerna的要求。
### 共享的 `devDependencies`
大多数`devDependencies`可以通过`lerna link convert`命令推送到Lerna仓库的根目录
上面的命令将自动吊装并使用相对目录的`file:`说明符。
吊装有一些好处:
- 所有软件包都使用给定依赖项的相同版本
- 可以通过自动化工具(例如[GreenKeeper](https://greenkeeper.io/)使根目录的依赖关系保持最新
- 依赖包安装时间减少
- 需要更少的存储空间
请注意,`devDependencies`仍然需要将npm脚本使用的“二进制”可执行文件直接安装在使用它们的每个软件包中。
请注意,`devDependencies`依赖包中如果有被npm scripts使用的”二进制“可执行文件的话,则仍然需要在需要它们的包中各自安装这些依赖。
例如,在以下场景下`nsp`依赖对于`lerna run nsp` 的正常运行来说是必需的(`npm run nsp`在包目录中)。
```json
{
"scripts": {
"nsp": "nsp"
},
"devDependencies": {
"nsp": "^2.3.3"
}
}
```
### Git主机的依赖项
Lerna允许将本地独立包的目标版本写成`committish`格式的[git remote url](https://docs.npmjs.com/cli/install),而不仅仅是普通的数字版本范围。当包必须是私有的并且[不使用私有的npm registry](https://www.dotconferences.com/2016/05/fabien-potencier-monolithic-repositories-vs-many-repositories)时,这允许包通过git仓库获取。`committish``#v1.0.0``#semver:^1.0.0`
请注意,lerna并*没有*执行把git history实际分离到独立的只读存储库。这是仓库管理者的责任。(有关实现的详细信息,请参[见此评论](https://github.com/lerna/lerna/pull/1033#issuecomment-335894690)
```json
// packages/pkg-1/package.json
{
name: "pkg-1",
version: "1.0.0",
dependencies: {
"pkg-2": "github:example-user/pkg-2#v1.0.0"
}
}
// packages/pkg-2/package.json
{
name: "pkg-2",
version: "1.0.0"
}
```
在上面的示例中,
- `lerna bootstrap`将正确链接`pkg-2``pkg-1`
- `lerna publishd``pkg-2`更改时,将会在`pkg-1`中更新committish(`#v1.0.0`)
+320
View File
@@ -0,0 +1,320 @@
### 基本概念
#### 依赖注入
> 依赖注入是一种允许我们从硬编码的依赖中解耦出来,从而在运行时或者编译时能够修改的软件设计模式。
以下代码解释了依赖注入的方便性
```php
// SendMessage.php
<?php
/**
* 在OneDriver中只要实现一个Xsend方法即可,代表了不同的短信发送提供商的驱动接口
*/
class SendMessage implements SendMessageInterface
{
protected $sdk;
public function __construct(OneDriver $sdk)
{
$this->sdk = $sdk;
}
public function send($content)
{
$this->sdk->Xsend($this->phone,$content);
}
...
}
```
```php
//SendController.php
...
public function sendMessage(SendMessageInterface $send)
{
$content = 'Hello My God!';
$send->setPhone('+86.xxxxxxxxxxx')->send($content);
}
```
```php
//index.php
$controller = new SendController();
$controller->sendMessage($container->make('send.message'));
```
依赖注入的优点:
- 如果所有的类和函数直接类名或函数名来获取对象,那么由于高度的耦合度测试将显得相当困难。
- 注入接口的好处是在类内部实现有所改变时我们将只需更改类本身而不需关心调用他的其它类或者代码
#### 服务容器
服务容器包含了2个东西。
1. 用于实现依赖注入的服务解析器
2. 用于管理依赖注入的容器
由于PHP原生并不支持依赖注入,所以我们要实现依赖注入则首先必须实现服务的解析,可以解析后即可实现用于管理所有服务的容器。为了方便,一般在PHP框架及类库中一体化服务与容器.
### 轻松实现
> 具体代码请查看本课时源代码中的step1
我们自己可以仿造Laravel或者Symfony的模式来轻松的实现一个自己的服务容器
#### 创建容器
整个服务容器的所有服务皆存储与Container容器类中的$app变量中
```php
// Container.php
...
public function bind($name, $definition){} //绑定服务
public function make($name){} //解析服务
public function alias($serviceName,$alias){} //设置服务的别名
public function all(){} //获取所有服务
public function has($name){} //判断服务是否存在在容器内
```
#### 实现容器
> Reflection类库用于PHP的反转控制,是实现DI的核心
在实现过程中,我们用的最多的是ReflectionClass类和ReflectionFunction类,这两个类用于处理PHP的类和函数的反射机制.
它们都是Reflection类的子集,具体使用请查看[官方API](http://php.net/manual/zh/book.reflection.php),这里就不再赘述。。。
我们假定服务有三种模式
- 闭包函数服务
- 对象绑定服务
- 类解析服务
那么我们要在bind(绑定方法)和make(解析方法)中分别对着三种模式进行处理
比如我们添加了factory方法与getArguments方法
factroy方法用于在解析的时候分别处理这三种模式
1. 对象绑定模式则直接返回对象即可
2. 闭包函数模式,则可能会在函数的参数中注入其它服务,所以要单独处理
3. 类解析服务则可能会在类的构造函数中注入其它服务,所以要单独处理
最后我们单独编写一个getArguments方法用于处理参数中含有其它服务的方法用于获取解析后的参数用于factory方法,还有一个值得注意的是alias,我们给一个处理绑定了服务名之后,我们添加一个alias方法,就可以为这个服务设置别名了.我们这里模仿laravel使alias可以为数组亦可为单个字符串别名(如果设置接口或者类名,在数组中会自动转换为字符串)
#### 测试容器
在实现服务容器后,我们编写一些代码用于测试服务容器是否有效
> 我们这里的服务容器是参照Laravel的模式编写,但是建议大家尝试改进,可以模仿Symfony的方法.Symfony是用service.yaml的方式来标准化服务容器,代码较Laravel更加优雅亦读
```php
// services.php
...
$container = new Container();
$container->bind('onedriver',function (){
return new OneDriver();
});
$container->bind('send.message',SendMessage::class);
$container->alias('onedriver',OneDriver::class);
$container->alias('send.message',[
SendMessageInterface::class
]);
```
绑定所有服务和设置别名之后即可访问index.php看到效果
上面我们自己写的服务容器为了简便起见很多没有实现,有兴趣的朋友可以结合Composer的自动加载类模式,参照Laravel和Symfony的方案自己扩展一下,就可以实现完整的服务容器方案咯
### Laravel与依赖注入
#### 容器方法
在Laravel中,依赖注入也是采用服务容器的设计模式,以下是它的一些常用容器方法,具体可以去看他的[API文档](https://laravel-china.org/api/5.4/Illuminate/Container/Container.html)
> 我们上面的实现为了方便简易的完成了,大家可以看到下面Laravel就做的比较细致了,比如闭包函数处理模式和普通的类绑定就用一个bind方法,而对象绑定就单独一个instance,设置还设置了singleton这种一次性解析方法,还有tag模式等,大家可以参考一下
- $app->bound(判断服务是否存在)
- $app->resolved(服务解析事件)
- $app->bind(普通的绑定服务模式,当然通过最后一个shared参数就可以设置成共享服务)
- $app->bindIf(按条件绑定)
- $app->singleton(只解析一次,产生的对象后面就一直使用了,可以理解为共享服务)
- $app->instance(直接以对象做为服务来绑定,那当然也是共享服务了)
- $app->make(解析服务)
- $app->tag(创建服务标签,用于归类服务)
- $app->tagged(解析服务标签得到服务数组,并按绑定时候顺序可以通过整数键值获取服务)
- $app->offsetXXX(用于设置,读取,判断一些全局变量,比如你要设置一个系统变量,那么就用他)
- $app->alias(老朋友了,我们上面就自己实现了,用于设置服务别名)
- $app->isAlias和$app->isShared(这就不用解释了)
- $app->when,$app->needs,$app->give(画蛇添足的地方,其实按条件解析如果实现同一个接口注入后,在依赖类中使用一个give方法就解析不同服务不是更好吗?用when,needs不仅没解决问题还增加了耦合度。。。)
- 。。。其它的自己看一下就明白了
#### 具体使用
因为Laravel是使用Composer机制,所以不需要像我们上面写的一样,自己require文件,设置好命名空间就能加载类了,这里重要讲解的是Laravel的Provider机制
> 类似Facades这种样子货大家自己看一下[文档](https://laravel-china.org/docs/5.4/facades)就明白了
首先大家要明白一点,Laravel中的Constract,Provider,Facades这些概念并不是什么高端的东西,非常好理解,我们还是回到我们上面自己写的服务容器案例。
对比一下Laravel的文档可以清楚的知道,所谓Constract不过是interface而已-接口,用于提供一套公有的方法,让具体实现的类来继承,然后通过服务绑定就可以解析了
##### 创建服务
$app->bind(服务名,"具体实现")
> "具体实现"可以是类,对象或者闭包什么的,绑定方法可以按自己的需要选择bind,singleton以及instance,
>
> 也可以根据条件绑定(bindIf),或者根据依赖类绑定(when,needs,give)
接着$app->alias(服务名,[一些Contract接口])
> 如果"具体实现"是一个类最好是继承这些接口,显得标准点,如果不是就别理会了
```php
/**
* 首先定义一个接口和一个类,这里省略命名空间
*/
namespace App\Services\Message\Contracts;
interface SendMessage
{
public function say();
}
namespace App\Services\Message;
class SendMessage implements SendMessageInterface
{
public function say()
{
echo 'Laravel is good!';
}
}
```
##### 创建提供者
在你的AppServiceProvider或者自定义的Provider中的register方法中这样做
```php
...
use App\Services\Message\Contracts\SendMessage as SendMessageInterface;
class MessageServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton('message.send',SendMessage::class);
$this->app->alias('message.send',SendMessageInterface::class);
}
}
```
##### 注册提供者
> 如果你的绑定是放在AppServiceProvider或者其它Laravel应用自动生成的提供者里面则不用管这一步了,因为这些提供者已经注册好了
有两种方法可以绑定注册提供者
1. 可以选择在config/app.php的'providers'数组中加载你的MessageServiceProvider
2. 在AppServiceProvider中的register方法里面用$this->app->register(MessageServiceProvider::class)注册
```php
//config/app.php
...
'providers' => [
App\Services\Message\MessageServiceProvider::class,
]
```
##### 使用门面
如果你想要个Facades(门面)就文档自己弄一个Facades类就可以了,当然也可以在MessageServiceProvider的boot方法中使用
```php
public function boot()
{
AliasLoader::getInstance
}
```
> Laravel5.4可以自动让任何类变成门面
你用5.4版本并且你的服务仅仅是一个类的话的话直接可以像这样调用门面,而不需要任何其它附加的注册
```php
use Facades\ {
App\Services\Message\SendMessage;
};
```
##### 解析服务
> 当然也可以使用门面,但是不建议多使用,这东西很废(在blade或twig模板里除外)
在控制器中通过Constract接口注入请求方法
```php
use App\Services\Message\Contracts\SendMessage;
class ArticleController extends Controller
{
public function create(SendMessage $message)
{
$hello->say();
}
}
```
在控制器中通过构造方法注入Application接口调用或者在构造方法中直接注入Contract接口
```php
class ArticleController extends Controller
{
protected $app;
public function __(Application $app)
{
$this->app = $app;
}
public function say()
{
$hello = $this->app->make('message.send'); //或者
$hello = $this->app['message.send']; //或者
$hello = app('message.send');
// 链式写法
$this->app->make('hmessage.send')->say();
}
}
```
在其它非控制器类中使用
> 如果SendMessage使用的地方不方便注入Application的话,可以丢弃这个构造函数,直接使用app('message.send')获取服务
```php
/**
* 在其它非控制器类中使用
*/
class CustomClass
{
public function say(message.send $hello)
{
$hello->say();
}
}
class UserSay
{
protected $app;
public function __construct(Application $app)
{
$this->app = $app;
}
public function say()
{
$custom = new CustomClass();
$custom->say($this->app->make('message.send'));
}
}
```
closure
+372
View File
@@ -0,0 +1,372 @@
<h1 align="center">
<a href="https://github.com/onury/accesscontrol"><img width="465" height="170" src="https://raw.github.com/onury/accesscontrol/master/ac-logo.png" alt="AccessControl.js" /></a>
</h1>
### 基于角色和属性的Node.js访问控制
虽然许多[RBAC][rbac](基于角色的访问控制)实现上有所不同,但基础知识都是一样的,这种模式也被广泛采用,因为它模拟了真实生活角色(任务)分配。 但是数据变得越来越复杂; 您需要在资源,功能甚至环境中定义策略。 这称为 [ABAC][abac] (基于属性的访问控制)。
我们需要合并以上两者的最佳特性(见[NIST paper][nist-paper]; 这个node库不仅实现了RBAC基础知识,并且还关注* resource ** action *属性。
## 核心功能
- 链式的,友好的API。
例如`ac.can(role).create(resource)`
- 角色分层**继承**。
- 可**一次**定义授权(例如从数据库结果)也可以**逐个**定义授权。
- 通过**glob表示法**定义的属性授予/拒绝权限(支持嵌套对象)。
- 能够设置可允许的属性**过滤**数据(模型)实例。
- 能够控制**自己创建**的或**任何**资源的访问。
- 能够**锁定**基础授权模型。
- 没有**静默**错误。
- **快**。(授权存储在内存中,没有数据库查询。)
- **经过**严格**测试**。
- TypeScript支持。
*为了构建更加健壮的应用,这个库(v1.5.0 +)完全用TypeScript重写*
## 安装
使用 [**npm**](https://www.npmjs.com/package/accesscontrol): `npm i accesscontrol --save`
使用 [**yarn**](https://yarn.pm/accesscontrol): `yarn add accesscontrol`
## 使用指南
```js
const AccessControl = require('accesscontrol');
// or:
// import { AccessControl } from 'accesscontrol';
```
### 基础示例
逐个定义 roles(权限) 和grants(角色)
```js
const ac = new AccessControl();
ac.grant('user') // 定义新角色或修改现有角色。也可以是一个数组。
.createOwn('video') // 与.createOwn('video', ['*'])相同 ['*']为默认参数
.deleteOwn('video')
.readAny('video')
.grant('admin') // 切换到另一个角色而不破坏操作链
.extend('user') // 继承角色功能。一样,也可以是一个数组
.updateAny('video', ['title']) // 明确定义可操作的属性
.deleteAny('video');
const permission = ac.can('user').createOwn('video');
console.log(permission.granted); // —> true
console.log(permission.attributes); // —> ['*'] (所有属性)
permission = ac.can('admin').updateAny('video');
console.log(permission.granted); // —> true
console.log(permission.attributes); // —> ['title']
```
### Express.js 示例
检查所请求资源和操作的角色权限,如果已授权则返回权限筛选出的属性进行响应
```js
const ac = new AccessControl(grants);
// ...
router.get('/videos/:title', function (req, res, next) {
const permission = ac.can(req.user.role).readAny('video');
if (permission.granted) {
Video.find(req.params.title, function (err, data) {
if (err || !data) return res.status(404).end();
// filter data by permission attributes and send.
res.json(permission.filter(data));
});
} else {
// resource is forbidden for this user/role
res.status(403).end();
}
});
```
## 角色
您可以通过轻松地调用`AccessControl`实例上的方法`.grant(<role>)``.deny(<role>)`方法来创建/定义角色。
- 角色也可以继承自其它角色.
```js
// 用户角色继承查看者角色权限
ac.grant('user').extend('viewer');
// 管理员角色继承普通用户和编辑员的角色权限
ac.grant('admin').extend(['user', 'editor']);
// 管理员和超级管理员角色都继承了版主权限
ac.grant(['admin', 'superadmin']).extend('moderator');
```
- 继承是通过引用完成的,因此您可以在继承角色之前或之后授予资源权限。
```js
// 案例 #1
ac.grant('admin').extend('user') // 假设用户角色已经存在
.grant('user').createOwn('video');
// 案例 #2
ac.grant('user').createOwn('video')
.grant('admin').extend('user');
// 以下结果对于两种情况都是相同的
const permission = ac.can('admin').createOwn('video');
console.log(permission.granted); // true
```
继承说明:
- 角色不能自我继承。
- 不允许交叉继承。
例如`ac.grant('user').extend('admin').grant('admin').extend('user')`将抛出异常。
- 角色不能(预)继承不存在的角色。换句话说,您应该首先创建基本角色。例如`ac.grant('baseRole').grant('role').extend('baseRole')`
## 动作和动作属性
[CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)操作是您可以对资源执行的操作。有两个动作属性定义了资源的**所有权**:*own*和*any*。
例如,一个`admin`角色可以`create``read``update``delete`CRUD**任何** `account`资源。但是,一个`user`角色可能只`read``update`它**自己的** `account`资源。
<table>
<thead>
<tr>
<th>操作</th>
<th>所有权</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="2">
<b>C</b>reate<br />
<b>R</b>ead<br />
<b>U</b>pdate<br />
<b>D</b>elete<br />
</td>
<td>自己的资源</td>
<td>可以(或不可以)对当前请求的自己的资源执行C|R|U|D 操作。</td>
</tr>
<tr>
<td>任何资源</td>
<td>可以(或不可以)对任何资源执行C|R|U|D 操作,包括自己的资源。</td>
</tr>
</tbody>
</table>
```js
ac.grant('role').readOwn('resource');
ac.deny('role').deleteAny('resource');
```
*请注意,操作**自己的资源**也要求您检查实际所有权。[查看此处](https://github.com/onury/accesscontrol/issues/14#issuecomment-328316670) 获取更多信息。*
## 资源和资源属性
多个角色可以访问特定资源。但是根据上下文,您可能需要限制特定角色可访问的资源内容。
这可以通过资源属性实现。您可以使用Glob表示法来定义允许或拒绝的资源属性。
例如,我们有一个`video`具有以下属性的资源:`id``title``runtime``admin`角色可以读取*任何* `video`资源的所有属性:
```js
ac.grant('admin').readAny('video', ['*']);
// 也可以这样写:
// ac.grant('admin').readAny('video');
```
但是`id`属性不应该被`user`角色读取。
```js
ac.grant('user').readOwn('video', ['*', '!id']);
// 也可以这样写:
// ac.grant('user').readOwn('video', ['title', 'runtime']);
```
你也可以嵌套对象 (属性).
```js
ac.grant('user').readOwn('account', ['*', '!record.id']);
```
## 检查权限和过滤属性
你可以调用`AccessControl`实例的`.can(<role>).<action>(<resource>)`方法来检查对指定资源和行为的权限。
```js
const permission = ac.can('user').readOwn('account');
permission.granted; // true
permission.attributes; // ['*', '!record.id']
permission.filter(data); // filtered data (without record.id)
```
见 [express.js 示例](#expressjs-example).
## 一次定义所有授权
你可以一次把所有授权传递给 `AccessControl` 的构造方法.
它也可以接受一个对象 `Object`:
```js
// 这实际上是如何在内部维护授权的方案
let grantsObject = {
admin: {
video: {
'create:any': ['*', '!views'],
'read:any': ['*'],
'update:any': ['*', '!views'],
'delete:any': ['*']
}
},
user: {
video: {
'create:own': ['*', '!rating', '!views'],
'read:own': ['*'],
'update:own': ['*', '!rating', '!views'],
'delete:own': ['*']
}
}
};
const ac = new AccessControl(grantsObject);
```
... 也可以传递一个 数组 (从数据库中获取时很有用):
```js
// 从数据库获取的授权列表 (必须在内部转换为如下格式的有效的grants对象)
let grantList = [
{ role: 'admin', resource: 'video', action: 'create:any', attributes: '*, !views' },
{ role: 'admin', resource: 'video', action: 'read:any', attributes: '*' },
{ role: 'admin', resource: 'video', action: 'update:any', attributes: '*, !views' },
{ role: 'admin', resource: 'video', action: 'delete:any', attributes: '*' },
{ role: 'user', resource: 'video', action: 'create:own', attributes: '*, !rating, !views' },
{ role: 'user', resource: 'video', action: 'read:any', attributes: '*' },
{ role: 'user', resource: 'video', action: 'update:own', attributes: '*, !rating, !views' },
{ role: 'user', resource: 'video', action: 'delete:own', attributes: '*' }
];
const ac = new AccessControl(grantList);
```
你可以随时设置授权...
```js
const ac = new AccessControl();
ac.setGrants(grantsObject);
console.log(ac.getGrants());
```
...除非你锁定它:
```js
ac.lock().setGrants({}); // throws after locked
```
## 适配nest.js
#### 一个基于[onury/accesscontrol](https://github.com/onury/accesscontrol)实现的Nestjs权限控制模块
#### 本模块提供什么 ?
在这个模块中,您将拥有开箱即用的以下所有功能(只用于Nest.js)。
* 它是**基于装饰器的,**因为大多数时候你会在你的路由中使用装饰器。
* 内置**ACGuard**,您可以直接使用它。
* 从任何地方都可以访问底层的**AccessControl**对象。
## 安装
* NPM:
```bash
npm install nest-access-control --save
```
* Yarn:
```bash
yarn add nest-access-control
```
---
#### 示例
> 查看示例目录以获取更多代码;
加入我们需要构建视频服务,以便用户可以与他人分享视频,但我们需要一些`admins`来控制这些视频。
1. 首先让我们定义角色:
为了构建我们的角色,我们需要这个`RolesBuilder`类,它继承自`accesscontrol`包的`AccessControl`类。
```ts
// app.roles.ts
export enum AppRoles {
USER_CREATE_ANY_VIDEO = 'USER_CREATE_ANY_VIDEO',
ADMIN_UPDATE_OWN_VIDEO = 'ADMIN_UPDATE_OWN_VIDEO',
}
export const roles: RolesBuilder = new RolesBuilder();
roles
.grant(AppRoles.USER_CREATE_ANY_VIDEO) // define new or modify existing role. also takes an array.
.createOwn('video') // equivalent to .createOwn('video', ['*'])
.deleteOwn('video')
.readAny('video')
.grant(AppRoles.ADMIN_UPDATE_OWN_VIDEO) // switch to another role without breaking the chain
.extend(AppRoles.USER_CREATE_ANY_VIDEO) // inherit role capabilities. also takes an array
.updateAny('video', ['title']) // explicitly defined attributes
.deleteAny('video');
```
> 专家提示 👍 :请将所有角色组织在一个文件中,例如: `app.roles.ts`。
2. 接着让我们在跟模块中使用`AccessControlModule`注册角色:
```ts
// app.module.ts
import { roles } from './app.roles';
@Module({
imports: [AccessControlModule.forRoles(roles)],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
```
直到现在一切都很好,现在让我们构建我们的应用程序,假设我们有视频名称列表,用户可以 - *根据我们的角色* - `create:own`新视频或`read:any`视频,好的,让我们开始构建它
```ts
// app.controller.ts
...
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@UseGuards(AuthGuard, ACGuard)
@UseRoles({
resource: 'video',
action: 'read',
possession: 'any',
})
@Get()
root(@UserRoles() userRoles: any) {
return this.appService.root(userRoles);
}
}
```
那么让我们讨论一下发生了什么!
首先我们介绍了两个新装饰器,实际上它们是三个,但让我们看看它们能做什么:
- `@UseRoles({ ... })`:这是最常用的装饰器,它定义用户允许访问此路由的角色。它可以设置一个或多个角色,但请记住,**必须**满足所有角色。角色的结构非常简单,例如,我们在这里定义了我们拥有的资源,以及**ACGuard \*** - 将检查用户角色,然后如果用户角色具有访问此资源的权限,则守卫将返回`true`,否则将抛出一个`ForbiddenException`。关于角色的结构的更多信息请参阅`roles.interface.ts`文件或读取`accesscontrol`库的[原始文档](https://onury.io/accesscontrol/)。
- `UserRoles(<prop>)`:如果你想直接访问用户角色,也许你就想手动检查它的角色,而不是让`ACGuard`为你做这些,然后你就会寻找`ACGuard`这个装饰器。这个装饰器它其实很简单,它只是获取`req.user.roles``request`对象返回值,但是等等,如果用户的角色中不存在`prop: role`,我们知道你会问这个问题,这样你就可以将一个可选的属性键传递给装饰器了以便从用户对象获取它,例如`@UserRoles('permissions')`将返回`req.user.permissions`
- `@InjectRolesBuilder()`: If you hate the `ACGuard` - _imo it's a good guard_ - and want to build your own Guard instead, you will likely need to access to the underlying `RolesBuilder` Object , then that decorator for you, it will inject the `Roles` you have defined before, i.e the object passed to the `AccessControlModule.forRoles(roles)`.
- `@InjectRolesBuilder()`:如果你不喜欢的`ACGuard`- *这是一个很好的守卫* -如果你想建立自己的守卫代替它,您可能需要访问底层`RolesBuilder`对象,那么这个装饰器将会注入你之前已定义的`Roles`,即传递给`AccessControlModule.forRoles(roles)`的对象。
#### 限制
首先,这个模块假设以下情况
1.`req.user`存在用户对象
2. 您可以自己构建`AuthGuard``user`对象附加到`req`对象的内容,[查看详细方法](https://docs.nestjs.com/guards)
3. the `AuthGuard` must be registered before roles guard, in this case it's `ACGuard`, and of course you can combine the `AuthGuard` and `ACGuard` in one guard, and use it everywhere.
4. `AuthGuard`必须在roles守卫之前注册,在这个案例中roles守卫就是`ACGuard`,当然你可以把`AuthGuard``ACGuard`放在一个守卫中,并在任何地方使用它。
其次,我不认为这些是限制,因为你可以轻松地建立自己的守卫,而不再需要内置的了。
+326
View File
@@ -0,0 +1,326 @@
## 内置hooks
### UseState
```typescript
const App: FC = () => {
const [count, setCount] = useState(0);
const [isShow, toggleShow] = useState(true);
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>increment +</button>
<p>{isShow ? <span> I'm show now </span> : null}</p>
<button onClick={() => toggleShow(!isShow)}>open sidebar</button>
</>
);
};
```
![](https://pic.phpna.com/react-notes/20190920010500.gif)
### UseEffect
函数体用于注册事件,返回值用于注销事件
#### 没有第二个参数
1. 组件挂载(首次渲染)只执行useEffect函数体
2. 组件更新先执行返回值函数,再执行函数体
3. 组件卸载只执行返回值函数
#### 第二个参数为空数组
1. 组件挂载(首次渲染)只执行函数体
2. 组件更新不执行,直接跳过
3. 组件卸载只执行返回值函数
#### 第二个参数为可变值
1. 组件挂载(可变值初始化后)只执行函数体
2. 只当可变值变化先执行返回值函数,再执行函数体
3. 组件卸载只执行返回值函数
![](https://pic.phpna.com/react-notes/20190920023556.png)
示例: 写一个拖动浏览器实时显示窗口宽度的组件,注意不要忘记把这个组件放入`App`组件中
```tsx
const EffectComponent: FC = () => {
const [width, setWidth] = useState(window.innerWidth);
const resizeHandle = () => {
console.log(window.innerWidth);
setWidth(window.innerWidth);
};
useEffect(() => {
window.addEventListener("resize", resizeHandle);
return () => {
window.removeEventListener("resize", resizeHandle);
};
}, [width]);
return (
<>
<p>{width}</p>
</>
);
};
```
看一下效果
![](https://pic.phpna.com/react-notes/20190920034745.gif)
### useContext
创建演示组件
```tsx
// components/ContextDemo.tsx
...
import { LocalProps, LangType } from "../typing";
const langs: LangType[] = [
{ name: "en", label: "english" },
{ name: "zh-CN", label: "简体中文" }
];
const localContext = createContext(langs[0]);
const LocalContextProvider: FC<LocalProps> = props => {
const [localLang, setLocalLang] = useState<LangType>(
!props.lang ? props.lang! : langs[0]
);
useEffect(() => {
setLocalLang(props.lang!);
}, [props.lang]);
return (
<>
<localContext.Provider value={localLang}>
{props.children}
</localContext.Provider>
</>
);
};
const LocalSelector: FC<{ setLang: Function }> = props => {
const currentLang = useContext(localContext);
const changeCurrentLang = (event: ChangeEvent<HTMLSelectElement>) => {
props.setLang(langs.find(lang => lang.name === event.target.value));
};
return (
<>
<select name="LocalSelector" onChange={changeCurrentLang}>
{langs.map(lang => (
<option
value={lang.name}
key={lang.name}
selected={lang.name === currentLang.name}
>
{lang.label ? lang.label : lang.name}
</option>
))}
</select>
</>
);
};
const contextDemo = {
langs,
localContext,
LocalContextProvider,
LocalSelector
};
export default contextDemo;
```
在根组件导入组件
```tsx
// index.tsx
...
const App: FC = () => {
const [lang, setLang] = useState(langs[0]);
return (
<ContextDemo.LocalContextProvider lang={lang}>
...
<h2>useContext Demo</h2>
<ContextDemo.LocalSelector setLang={(lang: LangType) => setLang(lang)} />
</ContextDemo.LocalContextProvider>
);
};
```
`EffectDemo`组件使用`useContext`显示当前语言
```tsx
...
const currentLang = useContext(ContextDemo.localContext);
<p>
current lang is{" "}
{currentLang.label ? currentLang.label! : currentLang.name}
</p>
```
看一下效果
![](https://pic.phpna.com/react-notes/20190922195237.gif)
### useReducer
```tsx
// components/ReducerDemo.tsx
...
// 初始化时默认主题名称
const defaultThemeName: string = "dark";
// 主题列表
const themes: ThemeType[] = [
{ name: "dark", label: "暗黑" },
{ name: "light", label: "明亮" }
];
// 过滤主题列表(删除非默认主题的isDefault属性)
const filterThemes = (data: ThemeType[]) =>
data.map(theme => {
if (theme.isDefault !== undefined && !theme.isDefault) {
delete theme.isDefault;
}
return theme;
});
// 获取初始化主题列表(设置初始化默认主题为当前默认主题),此函数用于初始化reducer的值
const getInitThemes = (data: ThemeType[]) => {
return filterThemes(
data.map(theme => {
theme.isDefault = theme.name === defaultThemeName;
return theme;
})
);
};
// 主题操作,为了便于演示这里我们只添加了一个切换默认主题的操作
const ThemeReducer = (state: ThemeType[], action: ThemeActionType) => {
switch (action.type) {
case "CHANGE_THEME":
return filterThemes(
state.map(theme => {
if (theme.name === action.value.name) {
return { ...theme, isDefault: true };
}
return { ...theme, isDefault: false };
})
);
default:
return filterThemes(state);
}
};
// 获取默认当前主题
const getDefaultTheme = (data: ThemeType[]) =>
filterThemes(data).find(theme => theme.isDefault!);
// 创建主题列表的全局变量
const themesContext = createContext<
[ThemeType[], Dispatch<ThemeActionType>] | null
>(null);
// 创建当前默认主题的全局变量
const defaultThemeContext = createContext(
getInitThemes(themes).find(theme => theme.name === defaultThemeName)
);
// 创建一个Provider高阶组件
const ThemeContextProvider: FC<{
children: ReactElement[] | ReactElement;
}> = props => {
// 把{主题列表状态,触发状态改变的dispatch}包装为全局变量
const contextValue = useReducer(ThemeReducer, themes, getInitThemes);
// 根据主题列表状态的变化使用getDefaultTheme动态的获取当前默认主题
const [currentThemes] = contextValue;
return (
<>
<themesContext.Provider value={contextValue}>
<defaultThemeContext.Provider value={getDefaultTheme(currentThemes)!}>
{props.children}
</defaultThemeContext.Provider>
</themesContext.Provider>
</>
);
};
// 自定义一个hooks用于其它组件便捷地通过themesContext全局变量来获取主题列表的state和dispatch
const useThemesReducer = () => {
const contextValue = useContext(themesContext);
return contextValue;
};
// 自定义一个Hooks通过全局变量defaultThemeContext来快捷地获取当前默认主题
const useTheme = (): ThemeType => {
const contextValue = useContext(defaultThemeContext);
return contextValue!;
};
// 根据主题对象获取主题的显示名
const getThemeText = (theme: ThemeType): string => {
return theme.label ? theme.label : theme.name;
};
// 主题选择器组件
const ThemeSelector: FC = () => {
const [themes, dispatch] = useThemesReducer()!;
const defaultTheme = useTheme();
const changeDefaultTheme = (event: ChangeEvent<HTMLSelectElement>) => {
dispatch({
type: ThemeActionName.CHANGE_THEME,
value: themes.find(theme => theme.name === event.target.value)!
});
};
return (
<>
<p>{defaultTheme.name}</p>
<select name="LocalSelector" onChange={changeDefaultTheme}>
{themes.map(theme => (
<option value={theme.name} key={theme.name}>
{theme.label ? theme.label : theme.name}-
</option>
))}
</select>
</>
);
};
export default {
useTheme,
useThemesReducer,
getThemeText,
ThemeContextProvider,
ThemeSelector
};
```
```tsx
// index.tsx
<ReducerDemo.ThemeContextProvider>
...
<ReducerDemo.ThemeSelector />
</ReducerDemo.ThemeContextProvider>
```
`EffectDemo`组件测试当前默认主题切换
```tsx
...
<p>current theme is {ReducerDemo.getThemeText(currentTheme)}</p>
```
![](https://pic.phpna.com/react-notes/20190923005620.gif)
+442
View File
@@ -0,0 +1,442 @@
依赖于[faker.js](https://github.com/marak/Faker.js/)typeorm-fixtures-cli允许您在开发或测试项目时创建大量的数据填充/假数据。它为您提供了一些基本工具,使您可以轻松地以编写用于生成复杂数据的易于读写的规则,以便团队中的每个人都可以根据需要生成自己的测试数据。
## 安装
#### NPM
```bash
npm install typeorm-fixtures-cli --save-dev
```
#### Yarn
```bash
yarn add typeorm-fixtures-cli --dev
```
## 开发步骤
```bash
# 安装依赖
npm install
# 构建编译文件
npm run build
```
## 示例
`fixtures/Comment.yml`
```yaml
entity: Comment
items:
comment{1..10}:
fullName: '{{name.firstName}} {{name.lastName}}'
email: '{{internet.email}}'
text: '{{lorem.paragraphs}}'
post: '@post*'
```
`fixtures/Post.yml`
```yaml
entity: Post
items:
post1:
title: '{{name.title}}'
description: '{{lorem.paragraphs}}'
user: '@user($current)'
post2:
title: '{{name.title}}'
description: '{{lorem.paragraphs}}'
user: '@user($current)'
```
`fixtures/User.yml`
```yaml
entity: User
items:
user1:
firstName: '{{name.firstName}}'
lastName: '{{name.lastName}}'
email: '{{internet.email}}'
profile: '@profile1'
__call:
setPassword:
- foo
user2:
firstName: '{{name.firstName}}'
lastName: '{{name.lastName}}'
email: '{{internet.email}}'
profile: '@profile2'
__call:
setPassword:
- foo
```
`fixtures/Profile.yml`
```yaml
entity: Profile
items:
profile1:
aboutMe: <%= ['about string', 'about string 2', 'about string 3'].join(", ") %>
skype: skype-account>
language: english
profile2:
aboutMe: <%= ['about string', 'about string 2', 'about string 3'].join(", ") %>
skype: skype-account
language: english
```
## 创建填充
此库的最基本功能是将扁平的yaml文件转换为对象
```yaml
entity: User
items:
user0:
username: bob
fullname: Bob
birthDate: 1980-10-10
email: bob@example.org
favoriteNumber: 42
user1:
username: alice
fullname: Alice
birthDate: 1978-07-12
email: alice@example.org
favoriteNumber: 27
```
### Fixture范围
第一步是创建一个对象的多个副本,以便从yaml文件中删除重复的规则。
您可以通过在fixture名称中定义范围来实现:
```yaml
entity: User
items:
user{1..10}:
username: bob
fullname: Bob
birthDate: 1980-10-10
email: bob@example.org
favoriteNumber: 42
```
现在它将生成十个用户,ID为user1到user10。相当不错,但我们只有10条相同的名字,用户名和电子邮件的数据,这还不是很花哨。
### Fixture引用
您还可以指定对先前创建的fixture列表的引用:
```yaml
entity: Post
items:
post1:
title: 'Post title'
description: 'Post description'
user: '@user1'
```
### Fixture列表
您也可以指定值列表而不是范围:
```yaml
entity: Post
items:
post{1..10}:
title: 'Post title'
description: 'Post description'
user: '@user($current)'
```
在一个设置了范围的案例中(例如,用户{1..10}),`($current)`将为user1返回1,为user2返回2。。。
current在迭代中也可以用作字符串值:
```yaml
entity: Post
items:
post{1..10}:
title: 'Post($current)'
description: 'Post description'
```
``Post($current)` 将返回Post1为post1Post2为post2。。。
您可以使用基本数学运算符来改变此输出:
```yaml
entity: Post
items:
post{1..10}:
title: 'Post($current*100)'
description: 'Post description'
```
``Post($current*100)` 将返回post100 for post1Post200 for post2。。。
### 调用方法
有时如果你想要调用一个方法来初始化更多数据,你可以像使用属性一样执行此操作,使用方法名称并为其提供一组参数。
```yaml
entity: User
items:
user{1..10}:
username: bob
fullname: Bob
birthDate: 1980-10-10
email: bob@example.org
favoriteNumber: 42
_call:
setPassword:
- foo
```
## 处理关联
```yaml
entity: User
items:
user1:
# ...
entity: Group
items:
group1:
name: '<{names.admin}>'
owner: '@user1'
members:
- '@user2'
- '@user3'
```
如果要创建10个user和10个group并让每个用户拥有一个组,则可以在使用fixture ranges时,使用`($current)`将其替换为每次迭代的当前ID
```yaml
entity: User
items:
user1:
# ...
entity: Group
items:
group{1..10}:
name: 'name'
owner: '@user($current)'
members:
- '@user2'
- '@user3'
```
如果您想要一个随机用户而不是一个固定用户,您可以使用通配符定义一个引用:
```yaml
entity: User
items:
user1:
# ...
entity: Group
items:
group{1..10}:
name: 'name'
owner: '@user*'
members:
- '@user2'
- '@user3'
```
或者
```yaml
entity: User
items:
user1:
# ...
entity: Group
items:
group{1..10}:
name: 'name'
owner: '@user{1..2}' # @user1 or @user2
members:
- '@user2'
- '@user3'
```
## 高级指南
### Parameters
You can set global parameters that will be inserted everywhere those values are used to help with readability. For example:
```yaml
entity: Group
parameters:
names:
admin: Admin
items:
group1:
name: '<{names.admin}>' # <--- set Admin
owner: '@user1'
members:
- '@user2'
- '@user3'
```
### Faker数据
本库整合了[faker.js](https://github.com/marak/Faker.js/)库。使用{{foo}},您可以调用Faker数据提供者来生成随机数据。
```yaml
entity: User
items:
user{1..10}:
username: '{{internet.userName}}'
fullname: '{{name.firstName}} {{name.lastName}}'
birthDate: '{{date.past}}'
email: '{{internet.email}}'
favoriteNumber: '{{random.number}}'
_call:
setPassword:
- foo
```
### EJS模板
本库与[EJS](https://github.com/mde/ejs)整合
```yaml
entity: Profile
items:
profile1:
aboutMe: <%= ['about string', 'about string 2', 'about string 3'].join(", ") %>
skype: skype-account>
language: english
```
### 加载处理器
处理器允许您在持久化之前和(或)之后处理对象。处理器必须实现:`IProcessor`
```typescript
import { IProcessor } from 'typeorm-fixtures-cli';
```
下面是一个示例:
`processor/UserProcessor.ts`
```typescript
import { IProcessor } from 'typeorm-fixtures-cli';
import { User } from '../entity/User';
export default class UserProcessor implements IProcessor<User> {
preProcess(name: string, object: any): any {
return { ...object, firstName: 'foo' };
}
postProcess(name: string, object: { [key: string]: any }): void {
object.name = `${object.firstName} ${object.lastName}`;
}
}
```
fixture 配置 `fixtures/user.yml`
```yaml
entity: User
processor: ../processor/UserProcessor
items:
user1:
firstName: '{{name.firstName}}'
lastName: '{{name.lastName}}'
email: '{{internet.email}}'
```
## 用法
```
Usage: fixtures [options] <path> Fixtures folder/file path
Options:
-v, --version output the version number
-c, --config <path> TypeORM config path (default: "ormconfig.yml")
--require A list of additional modules. e.g. ts-node/register
-cn, --connection [value] TypeORM connection name (default: "default")
-s --sync Database schema sync
-d --debug Enable debug
-h, --help output usage information
--no-color Disable color
```
##### 需要多个附加模块
如果您一次使用多个模块(例如ts-node和tsconfig-paths),则可以提供多个require参数。例如:
```
fixtures ./fixtures --config ./typeorm.config.ts --sync --require=ts-node/register --require=tsconfig-paths/register
```
### 以编程方式加载fixtures
虽然typeorm-fixtures-cli旨在用作CLI,但您仍然可以通过程序中的API加载fixture。
例如,下面的代码片段将加载`./fixtures`目录中存在的所有fixture
```typescript
import * as path from 'path';
import { Builder, fixturesIterator, Loader, Parser, Resolver } from 'typeorm-fixtures-cli/dist';
import { createConnection, getRepository } from 'typeorm';
const loadFixtures = async (fixturesPath: string) => {
let connection;
try {
connection = await createConnection();
await connection.synchronize(true);
const loader = new Loader();
loader.load(path.resolve(fixturesPath));
const resolver = new Resolver();
const fixtures = resolver.resolve(loader.fixtureConfigs);
const builder = new Builder(connection, new Parser());
for (const fixture of fixturesIterator(fixtures)) {
const entity = await builder.build(fixture);
await getRepository(entity.constructor.name).save(entity);
}
} catch (err) {
throw err;
} finally {
if (connection) {
await connection.close();
}
}
};
loadFixtures('./fixtures')
.then(() => {
console.log('Fixtures are successfully loaded.');
})
.catch(err => console.log(err));
```
## 简单示例
- [typeorm-fixtures-sample](https://github.com/RobinCK/typeorm-fixtures-sample)
+892
View File
@@ -0,0 +1,892 @@
> 在看本文前最好先看一下[《阮一峰-es6中的装饰器》](http://es6.ruanyifeng.com/#docs/decorator)
装饰器用于给类,方法,属性以及方法参数等增加一些附属功能而不影响其原有特性。其在Typescript应用中的主要作用类似于Java中的注解,在AOP(面向切面编程)使用场景下非常有用。
> **面向切面编程(AOP)** 是一种编程范式,它允许我们分离[横切关注点](https://zh.wikipedia.org/wiki/横切关注点),藉此达到增加模块化程度的目标。它可以在不修改代码自身的前提下,给已有代码增加额外的行为(通知)
**装饰器一般用于处理一些与类以及类属性本身无关的逻辑**,例如: 一个类方法的执行耗时统计或者记录日志,可以单独拿出来写成装饰器。
看一下官方的解释更加清晰明了
> 装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。 装饰器使用 `@expression`这种形式,`expression`求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。
如果有使用过spring boot或者php的symfony框架的话,就基本知道装饰器的作用分别类似于以上两者注解和annotation,而node中装饰器用的比较好的框架是nest.js。不过不了解也没关系,接下来我就按我的理解讲解一下装饰器的使用。
不过目前装饰器还不属于标准,还在建议征集的第二阶段,但这并不妨碍我们在ts中的使用。只要在 `tsconfig.json`中开启 `experimentalDecorators`编译器选项就可以愉快地使用啦^^
```json
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
```
## 基本原理
可能有些时候,我们会对传入参数的类型判断、对返回值的排序、过滤,对函数添加节流、防抖或其他的功能性代码,基于多个类的继承,各种各样的与函数逻辑本身无关的、重复性的代码。
比如,我们要在用户登录的时候记录一下登录时间
```typescript
const logger = (now: number) => console.log(`lasted logged in ${now}`);
class User {
async login() {
await setTimeout(() => console.log('login success'), 100);
logger(new Date().valueOf());
}
}
```
以上代码把记录日志的代码强行写入登录的逻辑处理,这样代码量越高则代码越冗余。我们需要把日志逻辑单独拿出来,使login方法更专注于处理登录的逻辑,接下去我们用**高阶函数**模拟一下装饰器的原理,以便于后面更好的理解装饰器。
```typescript
type decoratorFunc = (
target: any,
key: string,
descriptor: PropertyDescriptor,
) => void;
// 模拟的装饰器工厂函数
const createDecorator = (decorator: decoratorFunc) => {
return (Model: any, key: string) => {
// 获取即将使用装饰器的类原型
const target = Model.prototype;
// 获取这个原型上某个方法的描述
const descriptor = Object.getOwnPropertyDescriptor(target, key);
// 更改描述,生成新的方法
decorator(target, key, descriptor);
};
};
const logger: decoratorFunc = (target, key, descriptor) => {
// 将修改后的函数重新定义到原型链上
Object.defineProperty(target, key, {
...descriptor,
value: async (...arg) => {
try {
return await descriptor.value.apply(this, arg); // 调用之前的函数
} finally {
const now = new Date().valueOf();
console.log(`lasted logged in ${now}`);
}
},
});
};
class User {
async login() {
console.log('login success');
await new Promise(resolve => setTimeout(resolve, 100));
}
}
const loggerDecorator = createDecorator(logger);
loggerDecorator(User, 'login');
const user = new User();
user.login();
// 控制台输出
// login success
// 停顿100ms
// lasted logged in 1571771681793
```
了解了以上概念,接下去让我们学习真正的装饰器。
## 装饰器类型
TS中的装饰器有几种类型,如下:
- 参数装饰器
- 方法装饰器
- 访问符装饰器
- 属性装饰器
- 类装饰器
以上每中装饰器分别可以作用于类原型(*prototype*属性)和类本身
### 类装饰器
[TS官方文档](https://www.typescriptlang.org/docs/handbook/decorators.html)中举了一个类装饰器的例子,也可以看一下。类装饰器其实就是把我们本身的类传入装饰器注解中,并对这个类或类的原型进行一些处理,仅此而已。例如:
```typescript
const UserDerorator = <T extends new (...args: any[]) => {}>(
constructor: T,
) => {
return class extends constructor {
newProperty = 'new property';
hello = 'override';
sayHello() {
return this.hello;
}
};
};
@HelloDerorator
export class UserService {
[key: string]: any; //此处用于防止eslint提示sayHello方法不存在
hello: string;
constructor() {
this.hello = 'test';
}
}
const user = new UserService()
console.log(user.sayHello())
// 控制台打印 override
```
#### 装饰器工厂
上面的方法我们为`UserService`添加了一个`HelloDerorator`装饰器,这个装饰器的属性将覆盖`UserService`的默认属性。为了方便给装饰器添加其它参数,我们把`HelloDerorator`改造成为一个装饰器工厂,如下:
```typescript
const WhoAmIDerorator = (firstname: string, lastname: string) => {
const name = `${firstname}.${lastname}`;
return <T extends new (...args: any[]) => {}>(target: T) => {
return class extends target {
_name: string = name;
getMyName() {
return this._name;
}
};
};
};
@WhoAmI('gkr', 'lichnow')
export class UserService {...}
const user = new UserService()
console.log(user.getMyName())
// 控制台打印 gkr.lichnow
```
#### 其它用法
我们还可以对类原型链`property`上的属性/方法和类本身的静态属性/方法进行赋值或重载操作,还可以重载构造函数,如下:
```typescript
interface UserProfile {
phone?: number;
address?: string;
}
const ProfileDerorator = (profile: UserProfile) => {
return (target: any) => {
const original = target;
let userinfo: string = '';
Object.keys(profile).forEach(key => {
userinfo = `${userinfo}.${profile[key].toString()}`;
});
// 添加一个原型属性
original.prototype.userinfo = userinfo;
// 使用函数创建一个新的类(类构造器),返回值为传入类的对象,这样就重载了构造函数
function constructor(...args: any[]) {
console.log('contruct has been changed');
return new original(...args);
}
// 赋值原型链
constructor.prototype = original.prototype;
// 添加一个静态属性
constructor.myinfo = `myinfo ${userinfo}`;
return constructor as typeof original;
};
};
// 因为静态属性是无法通过[key: string]: any;获取类型提示的,所以这里添加一个接口用于动态各类添加静态属性
interface StaticUser {
new (): UserService;
myinfo: string;
}
@ProfileDerorator({ phone: 133, address: 'zhejiang' })
class UserService {...}
console.log(((UserService as unknown) as StaticUser).myinfo);
// 控制台输出 myinfo .133.zhejiang
// 控制台输出 contruct has been changed
const user = new UserService()
console.log(user.userinfo)
// 控制台输出 .133.zhejiang
```
### 属性装饰器
属性装饰器一般不单独使用,主要用于配合类或方法装饰器进行组合装饰
#### 参数
属性装饰器函数有两个参数:
**target**
对于普通属性,target就是当前对象的原型,也就是说,假设 Employee 是对象,那么 target 就是 `Employee.prototype`
对于静态属性,target就是当前对象的类
**propertyKey**
属性的名称
#### 使用示例
```typescript
const userRoles = [];
// 通过属性装饰器把角色赋值给userRoles
const RoleDerorator = (roles: string[]) => {
return (target: any, propertyName: string) => {
roles.forEach(role => userRoles.push(role));
};
};
// 根据userRoles生成Roles对象并赋值给类原型的roles属性
const SetRoleDerorator = <
T extends new (...args: any[]) => {
[key: string]: any;
}
>(
constructor: T,
) => {
const roles = [
{ name: 'super-admin', desc: '超级管理员' },
{ name: 'admin', desc: '管理员' },
{ name: 'user', desc: '普通用户' },
];
return class extends constructor {
constructor(...args) {
super(...args);
this.roles = roles.filter(role => userRoles.includes(role.name));
}
};
};
@SetRoleDerorator
export class UserService {
@RoleDerorator(['admin', 'user'])
roles: string[] = [];
}
// 控制台输出 [ { name: 'admin', desc: '管理员' }, { name: 'user', desc: '普通用户' } ]
```
### 方法装饰器
在一开始我们介绍了装饰器的原理,其实这就是方法装饰器的原始实现。与属性装饰器不同的是,方法装饰器接受三个参数
> 方法装饰器重载的时候需要注意的一点是定义value务必使用function,而不是箭头函数,因为我们在调用原始的旧方法使用会使用到this,如:method.apply(this, args),这里的this指向需要function来定义,具体原因可参考我的另一篇文章[apply,bind,call使用](https://lichnow.com)
#### 参数
**target**
对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
**key**
方法名称
**descriptor: PropertyDescriptor**
方法的属性描述符(最重要的参数)
#### 属性描述符
属性描述包含以下几个属性
- configurable?: boolean; // 能否使用delete、能否修改方法特性或修改访问器属性
- enumerable?: boolean; 是否在遍历对象的时候存在
- value?: any; 用于定义新的方法代替旧方法
- writable?: boolean; 是否可写
- get?(): any; // 访问器
- set?(v: any): void; // 访问器
接下来我们使用方法装饰器修改一开始的[装饰器原理中的登录日志记录器](#基本原理)
```typescript
const loggerDecorator = () => {
return function logMethod(
target: Object,
propertyName: string,
propertyDescriptor: PropertyDescriptor,
): PropertyDescriptor {
const method = propertyDescriptor.value;
// 重载方法
propertyDescriptor.value = function async (...args: any[]) {
try {
return await method.apply(this, args); // 调用之前的函数
} finally {
const now = new Date().valueOf();
console.log(`lasted logged in ${now}`);
}
};
return propertyDescriptor;
};
};
class UserService {
...
@loggerDecorator()
async login() {
console.log('login success');
await new Promise(resolve => setTimeout(resolve, 100));
}
}
const user = new UserService();
user.login();
// 控制台输出结果与前面的例子相同
```
### 参数装饰器
一个类中每个方法的参数也可以有自己的装饰器。
> 与属性装饰器类似,参数装饰器一般不单独使用,而是配合类或方法装饰器组合使用
#### 参数
1. **target**: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
2. **key**:方法名称
3. **index**: 参数数组中的位置
比如我们需要格式化一个方法的参数,那么可以创建一个转么用于格式化的装饰器
```typescript
// 参数格式化配置
const parseConf: Function[] = [];
const parse = (parseTo: Function) => {
return (target: any, propertyName: string, index: number) => {
parseConf[index] = parseTo;
};
};
// 在函数调用前执行格式化操作
const parseFunc = (
target: Object,
propertyName: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
return {
...descriptor,
value(...args: any[]) {
// 获取格式化后的参数列表
const newArgs = parseConf.map((toParse, index) => toParse(args[index]));
return descriptor.value.apply(this, newArgs);
},
};
};
interface UserEntity {
id: number;
username: string;
}
class User {
private users: UserEntity[] = [
{ id: 1, username: 'admin' },
{ id: 2, username: 'lichnow' },
];
getUsers() {
return this.users;
}
@parseDecorator
delete(@parse((arg: any) => Number(arg)) id): UserService {
this.users = this.users.filter(userObj => userObj.id !== id);
return this;
}
}
const user = new User();
user.delete('1');
console.log(user.getUsers());
// 控制台输出: [ { id: 2, username: 'lichnow' } ]
```
### 访问器装饰器
访问器其实只是那些添加了`get`,`set`前缀的方法,用于使用调用属性的方式获取和设置一些属性的方法,类似于PHP中的魔术方法`__get`,`__set`。其装饰器使用方法与普通方法并无差异,只是在获取值的时候是调用描述符的`get``set`来替代`value`而已。
例如,我们添加一个*nickname*字段,给设置*nickname*添加一个自定义前缀,并禁止在遍历*user*对象时出现*nickname*的值,添加一个*fullname*字段,在设置*nickname*时添加一个字符串后缀生成。
```typescript
export const HiddenDecorator = () => {
return (
target: any,
propertyName: string,
descriptor: PropertyDescriptor,
) => {
descriptor.enumerable = false;
};
};
export const PrefixDecorator = (prefix: string) => {
return (
target: any,
propertyName: string,
descriptor: PropertyDescriptor,
) => {
return {
...descriptor,
set(value: string) {
descriptor.set.apply(this, [`${prefix}_${value}`]);
},
};
};
};
export class UserService {
...
private _nickname: string;
private fullname: string;
@HiddenDecorator()
@PrefixDecorator('gkr_')
get nickname() {
return this._nickname;
}
set nickname(value: string) {
this._nickname = value;
this.fullname = `${value}_fullname`;
}
}
const user = new UserService();
user.password = '123456';
user.nickname = 'lichnow';
console.log(user);
console.log(user.nickname);
// 第一个console.log控制台输出,可以看到遍历对象后并没有nickname字段的值
// UserService {
// users: [ { id: 1, username: 'admin' }, { id: 2, username: 'lichnow' } ],
// roles: [],
// hello: 'test',
// password: '123456',
// _nickname: 'gkr__lichnow',
// fullname: 'gkr__lichnow_fullname'
//}
// 第二个console.log控制台输出
// gkr__lichnow
```
## 装饰器写法
通过装饰器重载方法有许多写法,可以根据自己的喜好来,以下举例几种
### 继承法
一般用于类装饰器中添加属性或方法,例如:
```typescript
return <T extends new (...args: any[]) => {}>(target: T) => {
return class extends target {
getMyName() {
return this._name;
}
};
};
```
### 原型法
一般用于类装饰器上重载构造函数以及添加属性或方法,例如:
```typescript
const ProfileDerorator = (profile: UserProfile) => {
return (target: any) => {
const original = target;
function constructor(...args: any[]) {
console.log('contruct has been changed');
return new original(...args);
}
// 赋值原型链
constructor.prototype = original.prototype;
// 添加一个静态属性
constructor.myinfo = `myinfo ${userinfo}`;
return constructor as typeof original;
};
};
```
### 赋值法
一般用于方法装饰器上修改某个描述符,例如
```typescript
const loggerDecorator = () => {
return function logMethod(
target: Object,
propertyName: string,
propertyDescriptor: PropertyDescriptor,
): PropertyDescriptor {
const method = propertyDescriptor.value;
// 重载方法
propertyDescriptor.value = function async (...args: any[]) {...};
return propertyDescriptor;
};
};
```
### 展开法
与赋值法类似,只不过使用ES6+的展开语法,更容易理解和使用,例如
```typescript
const parseFunc = (
target: Object,
propertyName: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
return {
...descriptor,
value(...args: any[]) {
// 获取格式化后的参数列表
const newArgs = parseConf.map((toParse, index) => toParse(args[index]));
return descriptor.value.apply(this, newArgs);
},
};
};
```
## 元信息反射 API
元信息反射 API (例如 `Reflect`)能够用来以标准方式组织元信息。而装饰器中的*元信息反射*使用非常简单,外观上仅仅可以看做在类的某个方法上附加一些随时可以获取的信息而已。
使用之前我们必须先安装`reflect-metadata`这个库
```typescript
npm i reflect-metadata --save
```
并且在`tsconfig.json`中启用原信息配置
```json
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
```
### 基本使用
我们看一下TS官方的[示例](https://https://www.typescriptlang.org/docs/handbook/decorators.html#metadata)是如何通过反射API获取属性设计阶段的类型信息的。
**需要注意的是目前预定义的元信息只有三种**
- **类型元信息**: `design:type`
- **参数类型元信息**: `design:paramtypes`
- **返回类型元信息**: `design:returntype`
```typescript
import "reflect-metadata";
class Point {
x: number;
y: number;
}
class Line {
private _p0: Point;
private _p1: Point;
@validate
// 这句可以省略,因为design:type是预订属性
// @Reflect.metadata('design:type', Point)
set p0(value: Point) {
this._p0 = value;
}
get p0() {
return this._p0;
}
@validate
// @Reflect.metadata("design:type", Point)
set p1(value: Point) {
this._p1 = value;
}
get p1() {
return this._p1;
}
}
function validate<T>(
target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<T>,
) {
const set = descriptor.set;
descriptor.set = function(value: T) {
const type = Reflect.getMetadata('design:type', target, propertyKey);
if (!(value instanceof type)) {
throw new TypeError('Invalid type.');
}
set.apply(this, [value]);
};
return descriptor;
}
const line = new Line();
const p0 = new Point();
p0.x = 1;
p0.y = 2;
line.p1 = p0;
console.log(line);
// 控制台输出: Line { _p1: Point { x: 1, y: 2 } }
```
### 自定义元信息
除了使用类似`design:type`这种预定义的原信息外,我们也可以自定义信息,因为一般我们都是用`reflect-metadata`来自定义原信息的。比如我们可以在**删除用户**的方法上添加一个**角色判断**,只有拥有我们设定角色的用户才能删除用户,比如**管理员角色**,具体可参考以下代码:
```typescript
// 角色守卫
export const RoleGuardDecorator = (roles: string[]) => {
return function roleGuard(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
// 根据传入的参数定义守卫所需的角色
Reflect.defineMetadata('roles', roles, target, propertyKey);
const method = descriptor.value;
descriptor.value = function(...args: any[]) {
// 获取当前用户的角色
const currentRoles = target.getRoles();
// 获取我们定义的操作此方法所需的角色
const needRoles = Reflect.getMetadata('roles', target, propertyKey);
// 判断当前用户是否拥有所需的角色,没有则抛出异常
for (const role of needRoles) {
if (!currentRoles.includes(role)) {
throw new Error(`you have not permission to run ${propertyKey}`);
}
}
return method.apply(this, args);
};
return descriptor;
};
};
class UserService {
...
// 设定当前用户的角色
getRoles() {
return ['user'];
}
@RoleGuardDecorator(['admin'])
// 在装饰器中使用Reflect.defineMetadata()放定义roles只是为了方便封装
// 当然,我们也可以在方法上直接定义roles,如下
// Reflect.metadata('roles',['admin'])
@parseDecorator
delete(@parse((arg: any) => Number(arg)) id): UserService {
this.users = this.getUsers().filter(userObj => userObj.id !== id);
return this;
}
}
const user = new UserService();
user.delete(1);
console.log(user.getUsers());
// 控制台将输出异常
// Error: you have not permission to run delete
```
## 组合与顺序
每一个属性,参数或方法都可以使用多组装饰器。每个类型的装饰器的调用顺序也是不同的。
### 组合使用
我们可以对任意一个被装饰者调用多组装饰器,多组装饰器一般书写在多行上(当然你也可以写在一行上,多行书写只不过是个约定俗成的惯例),比如
```typescript
@RoleGuardDecorator
@parseDecorator
delete(@parse((arg: any) => Number(arg)) id): UserService
```
当多个装饰器应用于一个声明上,它们求值方式与[高阶函数](http://en.wikipedia.org/wiki/Function_composition)相似。在这个模型下,当复合*RoleGuardDecorator*和*parseDecorator*时,复合的结果(*RoleGuardDecorator* ∘ *parseDecorator*)(*delete*)等同于*RoleGuardDecorator*(*parseDecorator*(*delete*))。
同时,我们可以参考react中的[高阶](https://zh-hans.reactjs.org/docs/higher-order-components.html),原理相似
它们的调用步骤类似剥洋葱法,即:
1. 由上至下依次对装饰器表达式求值。
2. 求值的结果会被当作函数,由下至上依次调用。
我们使用装饰器工厂来包装一下`@parseDecorator`(`@RoleGuardDecorator`已经在上面的代码中包装为工厂),并且在调用和求值阶段`console.log`来测试一下
```typescript
export const parseDecorator = () => {
console.log('开始格式化数据');
return (
target: Object,
propertyName: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor => {
return {
...descriptor,
value(...args: any[]) {
const newArgs = parseConf.map((toParse, index) => toParse(args[index]));
console.log('格式化完毕');
return descriptor.value.apply(this, newArgs);
},
};
};
};
export const RoleGuardDecorator = (roles: string[]) => {
return function roleGuard(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
console.log('开始验证角色');
...
descriptor.value = function(...args: any[]) {
...
console.log('验证角色完毕');
return method.apply(this, args);
};
return descriptor;
};
};
export class UserService {
...
@RoleGuardDecorator(['admin'])
// 把parseDecorator改成parseDecorator()
@parseDecorator()
getRoles() {
// 提供验证角色为admin
return ['admin'];
}
}
const user = new UserService();
user.delete(1);
console.log(user.getUsers());
// 控制台输出
// 开始格式化数据
// 开始验证角色
// 验证角色完毕
// 格式化完毕
// [ { id: 2, username: 'lichnow' } ]
```
### 调用顺序
每种类型的装饰器的调用顺序是不同的,具体顺序如下:
1. *参数装饰器*,然后依次是*方法装饰器*,*访问符装饰器*,或*属性装饰器*应用到每个实例成员(即类原型的成员)。
2. *参数装饰器*,然后依次是*方法装饰器*,*访问符装饰器*,或*属性装饰器*应用到每个静态成员。
3. *参数装饰器*应用到构造函数(即类原型)。
4. *类装饰器*应用到类。
例如:我们使用元信息结合方法和参数装饰器来验证参数的*required*,其调用顺序为*参数装饰器*->*方法装饰器*
```typescript
const requiredMetadataKey = Symbol('required');
export const RequiredDecorator = (
target: Object,
propertyKey: string | symbol,
parameterIndex: number,
) => {
const existingRequiredParameters: number[] =
Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
existingRequiredParameters.push(parameterIndex);
Reflect.defineMetadata(
requiredMetadataKey,
existingRequiredParameters,
target,
propertyKey,
);
};
export const ValidateDecorator = (
target: any,
propertyName: string,
descriptor: TypedPropertyDescriptor<Function>,
) => {
const method = descriptor.value;
descriptor.value = function() {
const requiredParameters: number[] = Reflect.getOwnMetadata(
requiredMetadataKey,
target,
propertyName,
);
if (requiredParameters) {
for (const parameterIndex of requiredParameters) {
if (
parameterIndex >= arguments.length ||
arguments[parameterIndex] === undefined
) {
throw new Error('Missing required argument.');
}
}
}
return method.apply(this, arguments);
};
};
@ValidateDecorator
createUser(@RequiredDecorator username?: string, id?: number) {
const ids: number[] = this.users.map(userEntity => userEntity.id);
const newUser: UserEntity = {
// 如果不提供ID参数,则新用户的ID为所有用户的最大ID + 1
id: id || Math.max(...ids) + 1,
// 如果不提供username参数,则生成随机字符串作为用户名
username:
username ||
Math.random()
.toString(36)
.substring(2, 15),
};
this.users.push(newUser);
return newUser;
}
const user = new UserService();
user.createUser();
console.log(user.getUsers());
// 控制台抛出异常: Error: Missing required argument.
// 尝试去除required装饰器
@ValidateDecorator
createUser(@RequiredDecorator username?: string, id?: number) {...}
// 控制台输出
// [
// { id: 1, username: 'admin' },
// { id: 2, username: 'lichnow' },
// { id: 3, username: 'q5cb3pgfdhq' }
// ]
```
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
import { Configure } from '@/modules/config/configure';
import { ConfigureFactory } from '@/modules/config/types';
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: [
{
name: 'app',
path: '/',
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: [
...contentApi.tags.manager,
...rbacApi.tags.manager,
...userApi.tags.manager,
],
},
children: [
...contentApi.routes.manager,
...rbacApi.routes.manager,
...userApi.routes.manager,
],
},
],
};
};
export const api: ConfigureFactory<ApiConfig> = {
register: async (configure: Configure) => ({
title: configure.env.get('API_TITLE', `${await configure.get<string>('app.name')} API`),
auth: true,
docuri: 'api/docs',
default: configure.env.get('API_DEFAULT_VERSION', 'v1'),
enabled: [],
versions: { v1: await v1(configure) },
}),
};
+8
View File
@@ -0,0 +1,8 @@
import { toNumber } from 'lodash';
import { createAppConfig } from '@/modules/core/config';
export const app = createAppConfig((configure) => ({
port: configure.env.get<number>('APP_PORT', (v) => toNumber(v), 3000),
prefix: 'api',
}));
+6
View File
@@ -0,0 +1,6 @@
import { createContentConfig } from '@/modules/content/config';
export const content = createContentConfig(() => ({
searchType: 'meili',
htmlEnabled: false,
}));
+26 -13
View File
@@ -1,14 +1,27 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { toNumber } from 'lodash';
export const database = (): TypeOrmModuleOptions => ({
charset: 'utf8mb4',
logging: ['error'],
type: 'mysql',
host: '192.168.50.26',
port: 3306,
username: '3r',
password: '12345678',
database: '3r',
synchronize: true,
autoLoadEntities: true,
});
import { createDBConfig } from '@/modules/database/config';
import { ContentFactory } from '@/modules/database/factories/content.factory';
import ContentSeeder from '@/modules/database/seeders/content.seeder';
export const database = createDBConfig((configure) => ({
common: {
synchronize: true,
// 启用详细日志以便调试 SQL 错误
logging: configure.env.get('NODE_ENV') === 'development' ? ['error', 'query'] : ['error'],
// 启用最大日志记录
maxQueryExecutionTime: 1000,
},
connections: [
{
type: 'mysql',
host: configure.env.get('DB_HOST', '192.168.50.26'),
port: configure.env.get<number>('DB_PORT', (v) => toNumber(v), 3306),
username: configure.env.get('DB_USERNAME', '3r'),
password: configure.env.get('DB_PASSWORD', '12345678'),
database: configure.env.get('DB_NAME', '3r'),
seeders: [ContentSeeder],
factories: [ContentFactory],
},
],
}));
+8
View File
@@ -1 +1,9 @@
export * from './database.config';
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';
+8
View File
@@ -0,0 +1,8 @@
import { createMeiliConfig } from '@/modules/meilisearch/config';
export const meili = createMeiliConfig((configure) => [
{
name: 'default',
host: 'http://192.168.50.26:7700',
},
]);
+5
View File
@@ -0,0 +1,5 @@
import { QueueOptions } from '@/modules/core/types';
export const queue: () => QueueOptions = () => ({
redis: 'default',
});
+7
View File
@@ -0,0 +1,7 @@
import { RedisOptions } from '@/modules/core/types';
export const redis: () => RedisOptions = () => ({
host: '192.168.50.137',
port: 6379,
password: '123456&Qw',
});
+10
View File
@@ -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'),
});
+15
View File
@@ -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'),
});
+5
View File
@@ -0,0 +1,5 @@
import { createUserConfig } from '@/modules/user/config';
export const user = createUserConfig(() => ({
hash: 10,
}));
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from '@/modules/core/helpers/app';
import { buildCli } from '@/modules/core/helpers/command';
import { createOptions } from '@/options';
console.error('Raw argv:', process.argv);
console.log('This is the very beginning of bin.ts');
buildCli(createApp(createOptions));
+5 -16
View File
@@ -1,18 +1,7 @@
import { NestFactory } from '@nestjs/core';
import 'reflect-metadata';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { createApp, startApp } from './modules/core/helpers/app';
import { listened } from './modules/restful/utils';
import { createOptions } from './options';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
cors: true,
logger: ['error', 'warn'],
});
app.setGlobalPrefix('api');
await app.listen(process.env.PORT ?? 3000, () => {
console.log('api: http://localhost:3000');
});
}
bootstrap();
startApp(createApp(createOptions), listened);
+53
View File
@@ -0,0 +1,53 @@
import { resolve } from 'path';
import { ensureFileSync, readFileSync, writeFileSync } from 'fs-extra';
import { has, isNil, omit, set } from 'lodash';
import { parse } from 'yaml';
export class ConfigStorage {
protected _enabled = false;
protected _path = resolve(__dirname, '../../..', 'config.yaml');
protected _config: RecordAny = {};
get enabled() {
return this._enabled;
}
get path() {
return this._path;
}
get config() {
return this._config;
}
constructor(enabled?: boolean, filePath?: string) {
if (!isNil(enabled)) {
this._enabled = enabled;
}
if (this._enabled) {
if (!isNil(filePath)) {
this._path = filePath;
}
ensureFileSync(this._path);
const config = parse(readFileSync(this._path, 'utf-8'));
this._config = isNil(config) ? {} : config;
}
}
set<T>(key: string, value: T) {
ensureFileSync(this.path);
set(this._config, key, value);
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
}
remove(key: string) {
this._config = omit(this._config, [key]);
if (has(this._config, key)) {
omit(this._config, [key]);
}
writeFileSync(this.path, JSON.stringify(this._config, null, 4));
}
}
+20
View File
@@ -0,0 +1,20 @@
import { DynamicModule, Module } from '@nestjs/common';
import { Configure } from './configure';
@Module({})
export class ConfigModule {
static forRoot(configure: Configure): DynamicModule {
return {
global: true,
module: ConfigModule,
providers: [
{
provide: Configure,
useValue: configure,
},
],
exports: [Configure],
};
}
}
+144
View File
@@ -0,0 +1,144 @@
import { get, has, isArray, isFunction, isNil, isObject, omit, set } from 'lodash';
import { deepMerge, isAsyncFunction } from '../core/helpers';
import { ConfigStorage } from './ConfigStorage';
import { Env } from './env';
import { ConfigStorageOption, ConfigureFactory, ConfigureRegister } from './types';
interface SetStorageOption {
enabled?: boolean;
change?: boolean;
}
export class Configure {
protected inited = false;
protected factories: Record<string, ConfigureFactory<RecordAny>> = {};
protected config: RecordAny = {};
protected _env: Env;
protected storage: ConfigStorage;
get env() {
return this._env;
}
all() {
return this.config;
}
has(key: string) {
return has(this.config, key);
}
async initialize(configs: RecordAny = {}, options: ConfigStorageOption = {}) {
if (this.inited) {
return this;
}
this._env = new Env();
await this._env.load();
const { enable, filePath } = options;
this.storage = new ConfigStorage(enable, filePath);
for (const key of Object.keys(configs)) {
this.add(key, configs[key]);
}
await this.sync();
this.inited = true;
return this;
}
async get<T>(key: string, defaultValue?: T): Promise<T> {
if (!has(this.config, key) && defaultValue === undefined && has(this.factories, key)) {
await this.syncFactory(key);
return this.get(key, defaultValue);
}
return get(this.config, key, defaultValue);
}
set<T>(key: string, value: T, storage: SetStorageOption | boolean = false, append = false) {
const storageEnable = typeof storage === 'boolean' ? storage : !!storage.enabled;
const storageChange = typeof storage === 'boolean' ? false : !!storage.change;
if (storageEnable && this.storage.enabled) {
this.changeStorageValue(key, value, storageChange, append);
} else {
set(this.config, key, value);
}
return this;
}
async sync(name?: string) {
if (isNil(name)) {
for (const key in this.factories) {
await this.syncFactory(key);
}
} else {
await this.syncFactory(name);
}
}
protected async syncFactory(key: string) {
if (has(this.config, key) || !has(this.factories, key)) {
return this;
}
const { register, defaultRegister, storage, hook, append } = this.factories[key];
let defaultValue = {};
let value = isAsyncFunction(register) ? await register(this) : register(this);
if (!isNil(defaultRegister)) {
defaultValue = isAsyncFunction(defaultRegister)
? await defaultRegister(this)
: defaultRegister(this);
value = deepMerge(defaultValue, value, 'replace');
}
if (!isNil(hook)) {
value = isAsyncFunction(hook) ? await hook(this, value) : hook(this, value);
}
if (this.storage.enabled) {
value = deepMerge(value, get(this.storage.config, key, isArray(value) ? [] : {}));
}
this.set(key, value, storage && isNil(await this.get(key, null)), append);
return this;
}
add<T extends RecordAny>(key: string, register: ConfigureFactory<T> | ConfigureRegister<T>) {
if (!isFunction(register) && 'register' in register) {
this.factories[key] = register as any;
} else if (isFunction(register)) {
this.factories[key] = { register };
}
return this;
}
remove(key: string) {
if (this.storage.enabled && has(this.storage.config, key)) {
this.storage.remove(key);
this.config = deepMerge(this.config, this.storage.config, 'replace');
} else if (has(this.config, key)) {
this.config = omit(this.config, [key]);
}
return this;
}
async store(key: string, change = false, append = false) {
if (!this.storage.enabled) {
throw new Error('Must enable storage first');
}
this.changeStorageValue(key, await this.get(key, null), change, append);
return this;
}
protected changeStorageValue<T>(key: string, value: T, change = false, append = false) {
if (change || !has(this.storage.config, key)) {
this.storage.set(key, value);
} else if (isObject(get(this.storage.config, key))) {
this.storage.set(
key,
deepMerge(value, get(this.storage.config, key), append ? 'merge' : 'replace'),
);
}
this.config = deepMerge(this.config, this.storage.config, append ? 'merge' : 'replace');
}
}
+8
View File
@@ -0,0 +1,8 @@
export enum EnvironmentType {
DEVELOPMENT = 'development',
DEV = 'dev',
PRODUCTION = 'production',
PROD = 'prod',
TEST = 'test',
PREVIEW = 'preview',
}
+78
View File
@@ -0,0 +1,78 @@
import dotenv from 'dotenv';
import { findUpSync } from 'find-up';
import { readFileSync } from 'fs-extra';
import { isFunction, isNil } from 'lodash';
import { EnvironmentType } from '@/modules/config/constants';
export class Env {
async load() {
if (isNil(process.env.NODE_ENV)) {
process.env.NODE_ENV = EnvironmentType.DEVELOPMENT;
}
const envs = [findUpSync(['.env'])];
if (this.isDev()) {
envs.push(
findUpSync([`.env.${EnvironmentType.DEVELOPMENT}`, `.env.${EnvironmentType.DEV}`]),
);
} else if (this.isProd()) {
envs.push(
findUpSync([`.env.${EnvironmentType.PRODUCTION}`, `.env.${EnvironmentType.PROD}`]),
);
} else {
envs.push(findUpSync([`.env.${this.run()}`]));
}
const envFiles = envs.filter((file) => !isNil(file)) as string[];
const fileEnvs = envFiles
.map((file) => dotenv.parse(readFileSync(file)))
.reduce((o, n) => ({ ...o, ...n }), {});
const envConfig = { ...process.env, ...fileEnvs };
const envKeys = Object.keys(envConfig).filter((key) => !(key in process.env));
envKeys.forEach((key) => {
process.env[key] = envConfig[key];
});
}
run() {
return process.env.NODE_ENV as EnvironmentType & RecordAny;
}
isProd() {
return this.run() === EnvironmentType.PRODUCTION || this.run() === EnvironmentType.PROD;
}
isDev() {
return this.run() === EnvironmentType.DEVELOPMENT || this.run() === EnvironmentType.DEV;
}
get(): { [key: string]: string };
get<T extends BaseType = string>(key: string): T;
get<T extends BaseType = string>(key: string, parseTo?: ParseType<T>): T;
get<T extends BaseType = string>(key: string, defaultValue?: T): T;
get<T extends BaseType = string>(key: string, parseTo?: ParseType<T>, defaultValue?: T): T;
get<T extends BaseType = string>(key?: string, parseTo?: ParseType<T> | T, defaultValue?: T) {
if (!key) {
return process.env;
}
const value = process.env[key];
if (value !== undefined) {
if (parseTo && isFunction(parseTo)) {
return parseTo(value);
}
return value as T;
}
if (parseTo === undefined && defaultValue === undefined) {
return undefined;
}
if (parseTo && defaultValue === undefined) {
return isFunction(parseTo) ? undefined : parseTo;
}
return defaultValue! as T;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Configure } from './configure';
export interface ConfigStorageOption {
enable?: boolean;
filePath?: string;
}
export type ConfigureRegister<T extends RecordAny> = (configure: Configure) => T | Promise<T>;
export interface ConfigureFactory<T extends RecordAny, P extends RecordAny = T> {
register: ConfigureRegister<RePartial<T>>;
defaultRegister?: ConfigureRegister<T>;
storage?: boolean;
hook?: (configure: Configure, value: T) => P | Promise<P>;
append?: boolean;
}
export type ConnectionOption<T extends RecordAny> = { name?: string } & T;
export type ConnectionRst<T extends RecordAny> = Array<{ name?: string } & T>;
+27
View File
@@ -0,0 +1,27 @@
import { isNil } from 'lodash';
import { ConnectionOption, ConnectionRst } from './types';
export const createConnectionOptions = <T extends RecordAny>(
config: ConnectionOption<T> | ConnectionOption<T>[],
) => {
const options = (
Array.isArray(config) ? config : [{ ...config, name: 'default' }]
) as ConnectionRst<T>;
if (options.length <= 0) {
return undefined;
}
const names = options.map(({ name }) => name);
if (!names.includes('default')) {
options[0].name = 'default';
}
return options
.filter(({ name }) => !isNil(name))
.reduce((o, n) => {
const oldNames = o.map(({ name }) => name) as string[];
return oldNames.includes(n.name) ? o : [...o, n];
}, []);
};
+12
View File
@@ -0,0 +1,12 @@
import { ConfigureFactory, ConfigureRegister } from '../config/types';
import { ContentConfig } from './types';
export const defauleContentConfig: ContentConfig = { searchType: 'mysql', htmlEnabled: false };
export const createContentConfig: (
register: ConfigureRegister<RePartial<ContentConfig>>,
) => ConfigureFactory<ContentConfig> = (register) => ({
register,
defaultRegister: () => defauleContentConfig,
});
+21
View File
@@ -1,13 +1,34 @@
export enum PostBodyType {
/**
* HTML格式
*/
HTML = 'html',
/**
* Markdown格式
*/
MD = 'markdown',
}
export enum PostOrder {
/**
* 最新创建
*/
CREATED = 'createdAt',
/**
* 最新创建
*/
UPDATED = 'updatedAt',
/**
* 最新发布
*/
PUBLISHED = 'publishedAt',
/**
* 评论数量
*/
COMMENTCOUNT = 'commentCount',
/**
* 自定义排序
*/
CUSTOM = 'custom',
}
+77 -18
View File
@@ -1,26 +1,85 @@
import { Module } from '@nestjs/common';
import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as controllers from '@/modules/content/controllers';
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';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { PostSubscriber } from '@/modules/content/subscribers/post.subscriber';
import { PostService } from '@/modules/content/services/post.service';
import { DatabaseModule } from '@/modules/database/database.module';
@Module({
imports: [
TypeOrmModule.forFeature(Object.values(entities)),
DatabaseModule.forRepository(Object.values(repositories)),
],
controllers: Object.values(controllers),
providers: [...Object.values(services), PostSubscriber, SanitizeService],
exports: [
...Object.values(services),
DatabaseModule.forRepository(Object.values(repositories)),
],
})
export class ContentModule {}
import { addEntities, addSubscribers } from '@/modules/database/utils';
import { UserRepository } from '@/modules/user/repositories';
import { Configure } from '../config/configure';
import { defauleContentConfig } from './config';
import * as subscribers from './subscribers';
import { ContentConfig } from './types';
@Module({})
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))),
{
provide: PostService,
inject: [
repositories.PostRepository,
repositories.CategoryRepository,
repositories.TagRepository,
services.CategoryService,
UserRepository,
{ token: services.SearchService, optional: true },
],
useFactory(
postRepository: repositories.PostRepository,
categoryRepository: repositories.CategoryRepository,
tagRepository: repositories.TagRepository,
categoryService: services.CategoryService,
userRepository: UserRepository,
searchService: SearchService,
) {
return new PostService(
postRepository,
categoryRepository,
categoryService,
tagRepository,
userRepository,
searchService,
config.searchType,
);
},
},
];
const exports: ModuleMetadata['exports'] = [
...Object.values(services),
PostService,
DatabaseModule.forRepository(Object.values(repositories)),
];
if (config.searchType === 'meili') {
providers.push(services.SearchService);
exports.push(SearchService);
}
if (config.htmlEnabled) {
providers.push(SanitizeService);
exports.push(SanitizeService);
}
return {
module: ContentModule,
imports: [
await addEntities(configure, Object.values(entities)),
DatabaseModule.forRepository(Object.values(repositories)),
],
providers,
exports,
};
}
}
@@ -1,65 +1,54 @@
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 { CreateCategoryDto, QueryCategoryDto, UpdateCategoryDto } from '../dtos/category.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 { CategoryService } from '../services';
@ApiTags('分类查询')
@Depends(ContentModule)
@Controller('category')
export class CategoryController {
constructor(protected service: CategoryService) {}
/**
* Search category tree
*/
@Get('tree')
@Guest()
@SerializeOptions({ groups: ['category-tree'] })
async tree() {
return this.service.findTrees();
}
/**
* 分页查询分类列表
* @param options
*/
@Get()
@Guest()
@SerializeOptions({ groups: ['category-list'] })
async list(
@Query()
options: QueryCategoryDto,
options: PaginateDto,
) {
return this.service.paginate(options);
}
/**
* 查询分类明细
* @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,29 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
SerializeOptions,
} from '@nestjs/common';
import { Body, Controller, Delete, Get, Post, Query, SerializeOptions } from '@nestjs/common';
import { CreateCommentDto, QueryCommentDto, QueryCommentTreeDto } from '../dtos/comment.dto';
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,
DeleteCommentDto,
QueryCommentDto,
QueryCommentTreeDto,
} 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()
@@ -32,15 +69,28 @@ 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);
}
@Delete(':id')
/**
* 批量删除评论
* @param data
*/
@Delete()
@ApiBearerAuth()
@Permission(permissions.owner)
@SerializeOptions({ groups: ['comment-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.delete(id);
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,49 +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);
}
@Delete(':id')
@SerializeOptions({ groups: ['post-detail'] })
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.postService.delete(id);
/**
* 批量删除自己发布的文章
* @param data
*/
@Delete()
@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')
@ApiBearerAuth()
@SerializeOptions({ groups: ['post-list'] })
@Permission(permissions.owner)
async restore(
@Body()
data: RestoreDto,
) {
const { ids } = data;
return this.postService.restore(ids);
}
}
@@ -1,59 +1,44 @@
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 { CreateTagDto, QueryTagDto, UpdateTagDto } from '../dtos/tag.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 { TagService } from '../services';
@ApiTags('标签查询')
@Depends(ContentModule)
@Controller('tag')
export class TagController {
constructor(protected service: TagService) {}
/**
* 分页查询标签列表
* @param options
*/
@Get()
@Guest()
@SerializeOptions({})
async list(
@Query()
options: QueryTagDto,
options: PaginateDto,
) {
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(':id')
@SerializeOptions({})
async delete(@Param('id', new ParseUUIDPipe()) id: string) {
return this.service.delete(id);
}
}
+19 -17
View File
@@ -2,8 +2,8 @@ import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
@@ -13,25 +13,22 @@ import {
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { PaginateOptions } from '@/modules/database/types';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { IsTreeUnique } from '@/modules/database/constraints/tree.unique.constraint';
import { IsTreeUniqueExist } from '@/modules/database/constraints/tree.unique.exist.constraint';
@DtoValidation({ type: 'query' })
export class QueryCategoryDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@IsOptional()
limit = 10;
}
import { CategoryEntity } from '../entities';
@DtoValidation({ groups: ['create'] })
export class CreateCategoryDto {
@IsTreeUnique(CategoryEntity, {
groups: ['create'],
message: 'The Category names are duplicated',
})
@IsTreeUniqueExist(CategoryEntity, {
groups: ['update'],
message: 'The Category names are duplicated',
})
@MaxLength(25, {
always: true,
message: 'The length of the category name shall not exceed $constraint1',
@@ -40,6 +37,7 @@ export class CreateCategoryDto {
@IsOptional({ groups: ['update'] })
name: string;
@IsDataExist(CategoryEntity, { always: true, message: 'The parent category does not exist' })
@IsUUID(undefined, {
always: true,
message: 'The format of the parent category ID is incorrect.',
@@ -51,13 +49,17 @@ export class CreateCategoryDto {
@Transform(({ value }) => toNumber(value))
@Min(0, { always: true, message: 'The sorted value must be greater than 0.' })
@IsNumber(undefined, { always: true })
@IsInt({ always: true })
@IsOptional({ always: true })
customOrder?: number = 0;
}
@DtoValidation({ groups: ['update'] })
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {
@IsDataExist(CategoryEntity, {
groups: ['update'],
message: 'category id not exist when update',
})
@IsUUID(undefined, { message: 'The ID format is incorrect', groups: ['update'] })
@IsDefined({ groups: ['update'], message: 'The ID must be specified' })
id: string;
+25 -8
View File
@@ -2,8 +2,8 @@ import { PickType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
@@ -13,22 +13,29 @@ import {
import { toNumber } from 'lodash';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints/data.exist.constraint';
import { PaginateOptions } from '@/modules/database/types';
import { CommentEntity, PostEntity } from '../entities';
@DtoValidation({ type: 'query' })
export class QueryCommentDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
@IsDataExist(PostEntity, { message: 'The post does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
post?: string;
@@ -39,17 +46,27 @@ export class QueryCommentTreeDto extends PickType(QueryCommentDto, ['post']) {}
@DtoValidation()
export class CreateCommentDto {
@MaxLength(1000, { message: '' })
@IsNotEmpty({ message: '' })
@MaxLength(1000, { message: 'The length of the comment content cannot exceed $constraint1' })
@IsNotEmpty({ message: 'Comment content cannot be empty' })
body: string;
@IsDataExist(PostEntity, { message: 'The post does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsDefined({ message: 'The ID must be specified' })
@IsDefined({ message: 'The post ID must be specified' })
post: string;
@IsDataExist(CommentEntity, { message: 'The parent comment does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect', always: true })
@ValidateIf((value) => value.parent !== null && value.parent)
@IsOptional({ always: true })
@Transform(({ value }) => (value === 'null' ? null : value))
parent?: string;
}
@DtoValidation()
export class DeleteCommentDto {
@IsDataExist(CommentEntity)
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
@IsDefined({ each: true, message: 'The ID must be specified' })
ids: string[];
}
+12
View File
@@ -0,0 +1,12 @@
import { ArrayMinSize, IsArray, IsDefined, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
@DtoValidation()
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[];
}
@@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDefined, IsOptional, IsUUID } from 'class-validator';
import { toBoolean } from 'validator';
import { DeleteDto } from './delete.dto';
export class DeleteWithTrashDto extends DeleteDto {
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
trash?: boolean;
}
export class RestoreDto {
@IsUUID(undefined, { each: true, always: true, message: 'The ID format is incorrect' })
@IsDefined({ each: true, message: 'The ID must be specified' })
ids: string[];
}
+151 -8
View File
@@ -1,10 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { OmitType, PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
@@ -19,44 +20,96 @@ import { isNil, toNumber } from 'lodash';
import { PostOrder } from '@/modules/content/constants';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { toBoolean } from '@/modules/core/helpers';
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';
/**
* 文章分页查询验证
*/
@DtoValidation({ type: 'query' })
export class QueryPostDto implements PaginateOptions {
/**
* 是否查询已发布(全部文章:不填、只查询已发布的:true、只查询未发布的:false)
*/
@Transform(({ value }) => toBoolean(value))
@IsBoolean()
@IsOptional()
isPublished?: boolean;
/**
* 全文搜索
*/
@MaxLength(100, {
always: true,
message: '搜索字符串长度不得超过$constraint1',
})
@IsOptional()
search?: string;
/**
* 查询结果排序,不填则综合排序
*/
@IsEnum(PostOrder, {
message: `The sorting rule must be one of ${Object.values(PostOrder).join(',')}`,
})
@IsOptional()
orderBy: PostOrder;
orderBy?: PostOrder;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@Min(1, { always: true, message: 'The current page must be greater than 1.' })
@IsInt()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@Min(1, {
always: true,
message: 'The number of data displayed per page must be greater than 1.',
})
@IsInt()
@IsOptional()
limit = 10;
@IsEnum(SelectTrashMode)
@IsOptional()
trashed?: SelectTrashMode;
/**
* 根据分类ID查询此分类及其后代分类下的文章
*/
@IsDataExist(CategoryEntity, { always: true, message: 'The category does not exist' })
@IsUUID(undefined, { message: 'The ID format is incorrect' })
@IsOptional()
category?: string;
/**
* 根据标签ID查询
*/
@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',
@@ -65,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',
@@ -76,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,
@@ -90,12 +155,19 @@ export class CreatePostDto {
@IsOptional({ always: true })
keywords?: string[];
/**
* 自定义排序
*/
@Transform(({ value }) => toNumber(value))
@Min(0, { message: 'The sorted value must be greater than 0.' })
@IsNumber(undefined, { always: true })
@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,
message: 'The ID format is incorrect',
@@ -103,6 +175,14 @@ export class CreatePostDto {
@IsOptional({ always: true })
category?: string;
/**
* 关联标签ID
*/
@IsDataExist(TagEntity, {
always: true,
each: true,
message: 'The tag does not exist',
})
@IsUUID(undefined, {
always: true,
each: true,
@@ -110,14 +190,77 @@ 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.',
})
@IsDefined({ groups: ['update'], message: 'The article ID must be specified' })
@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']) {}
+8 -26
View File
@@ -1,36 +1,17 @@
import { PartialType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDefined,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
MaxLength,
Min,
} from 'class-validator';
import { toNumber } from 'lodash';
import { IsDefined, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { PaginateOptions } from '@/modules/database/types';
import { IsDataExist } from '@/modules/database/constraints';
import { IsUnique } from '@/modules/database/constraints/unique.constraint';
import { IsUniqueExist } from '@/modules/database/constraints/unique.exist.constraint';
@DtoValidation({ type: 'query' })
export class QueryTagDto implements PaginateOptions {
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The current page must be greater than 1.' })
@IsNumber()
@IsOptional()
page = 1;
@Transform(({ value }) => toNumber(value))
@Min(1, { message: 'The number of data displayed per page must be greater than 1.' })
@IsNumber()
@IsOptional()
limit = 10;
}
import { TagEntity } from '../entities';
@DtoValidation({ groups: ['create'] })
export class CreateTagDto {
@IsUnique(TagEntity, { groups: ['create'], message: 'The label names are repeated' })
@IsUniqueExist(TagEntity, { groups: ['update'], message: 'The label names are repeated' })
@MaxLength(255, {
always: true,
message: 'The maximum length of the label name is $constraint1',
@@ -49,6 +30,7 @@ export class CreateTagDto {
@DtoValidation({ groups: ['update'] })
export class UpdateTagDto extends PartialType(CreateTagDto) {
@IsDataExist(TagEntity, { groups: ['update'], message: 'tag id not exist when update' })
@IsUUID(undefined, { message: 'The ID format is incorrect', groups: ['update'] })
@IsDefined({ groups: ['update'], message: 'The ID must be specified' })
id: string;
@@ -1,11 +1,11 @@
import { Exclude, Expose, Type } from 'class-transformer';
import type { Relation } from 'typeorm';
import {
BaseEntity,
Column,
Entity,
OneToMany,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
@@ -22,7 +22,7 @@ export class CategoryEntity extends BaseEntity {
id: string;
@Expose()
@Column({ comment: '分类名称', unique: true })
@Column({ comment: '分类名称' })
name: string;
@Expose({ groups: ['category-tree', 'category-list', 'category-detail'] })
@@ -40,7 +40,7 @@ export class CategoryEntity extends BaseEntity {
parent: Relation<CategoryEntity> | null;
@Type(() => CategoryEntity)
@Expose({ groups: ['category-tree'] })
@Expose({ groups: ['category-tree', 'category-detail'] })
@TreeChildren({ cascade: true })
children: Relation<CategoryEntity>[];
}
@@ -1,4 +1,5 @@
import { Exclude, Expose, Type } from 'class-transformer';
import type { Relation } from 'typeorm';
import {
BaseEntity,
Column,
@@ -6,13 +7,13 @@ import {
Entity,
ManyToOne,
PrimaryColumn,
Relation,
Tree,
TreeChildren,
TreeParent,
} from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { UserEntity } from '@/modules/user/entities/user.entity';
@Exclude()
@Entity('content_comment')
@@ -49,4 +50,11 @@ export class CommentEntity extends BaseEntity {
@Expose({ groups: ['comment-tree'] })
@TreeChildren({ cascade: true })
children: Relation<CommentEntity>[];
@ManyToOne(() => UserEntity, (user) => user.comments, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
author: Relation<UserEntity>;
}
+20 -5
View File
@@ -1,14 +1,16 @@
import { Exclude, Expose, Type } from 'class-transformer';
import type { Relation } from 'typeorm';
import {
BaseEntity,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
@@ -16,6 +18,7 @@ import { PostBodyType } from '@/modules/content/constants';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { TagEntity } from '@/modules/content/entities/tag.entity';
import { UserEntity } from '@/modules/user/entities/user.entity';
@Exclude()
@Entity('content_posts')
@@ -36,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[];
@@ -46,7 +48,7 @@ export class PostEntity extends BaseEntity {
type: PostBodyType;
@Expose()
@Column({ comment: '发布时间', type: 'varchar', nullable: true })
@Column({ comment: '发布时间', type: 'timestamp', nullable: true })
publishedAt?: Date | null;
@Expose()
@@ -63,11 +65,16 @@ export class PostEntity extends BaseEntity {
@UpdateDateColumn({ comment: '更新时间', nullable: true })
updatedAt?: Date;
@Expose()
@Type(() => Date)
@DeleteDateColumn({ comment: '删除时间' })
deletedAt: Date;
@Expose()
commentCount: number;
@Expose()
@OneToMany(() => CategoryEntity, (category) => category.posts, {
@ManyToOne(() => CategoryEntity, (category) => category.posts, {
nullable: true,
onDelete: 'SET NULL',
})
@@ -75,10 +82,18 @@ export class PostEntity extends BaseEntity {
@Expose()
@Type(() => TagEntity)
@ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: true })
@ManyToMany(() => TagEntity, (tag) => tag.posts, { cascade: ['insert', 'update', 'remove'] })
@JoinTable()
tags: Relation<TagEntity>[];
@OneToMany(() => CommentEntity, (comment) => comment.post, { cascade: true })
comments: Relation<CommentEntity>[];
@Expose()
@ManyToOne(() => UserEntity, (user) => user.posts, {
nullable: false,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
author: Relation<UserEntity>;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Exclude, Expose } from 'class-transformer';
import { Column, Entity, ManyToMany, PrimaryColumn, Relation } from 'typeorm';
import type { Relation } from 'typeorm';
import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
+120
View File
@@ -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'],
},
]);
}
}
@@ -1,21 +1,23 @@
import { pick, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepository, TreeRepositoryUtils } from 'typeorm';
import { isNil, unset } from 'lodash';
import { FindOptionsUtils, FindTreeOptions, TreeRepositoryUtils } from 'typeorm';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { BaseTreeRepository } from '@/modules/database/base/tree.repository';
import { OrderType, TreeChildrenResolve } from '@/modules/database/constants';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
@CustomRepository(CategoryEntity)
export class CategoryRepository extends TreeRepository<CategoryEntity> {
export class CategoryRepository extends BaseTreeRepository<CategoryEntity> {
protected _qbName = 'category';
protected orderBy = { name: 'customOrder', order: OrderType.ASC };
protected _childrenResolve = TreeChildrenResolve.UP;
buildBaseQB() {
return this.createQueryBuilder('category').leftJoinAndSelect('category.parent', 'parent');
}
async findTrees(options?: FindTreeOptions) {
const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots;
}
findRoots(options?: FindTreeOptions): Promise<CategoryEntity[]> {
const escape = (val: string) => this.manager.connection.driver.escape(val);
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
@@ -39,28 +41,6 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
return qb.getMany();
}
async findDescendantsTree(entity: CategoryEntity, options?: FindTreeOptions) {
const qb = this.createDescendantsQueryBuilder('category', 'treeClosure', entity)
.leftJoinAndSelect('category.parent', 'parent')
.orderBy('category.customOrder', 'ASC');
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'category',
entities.raw,
);
TreeRepositoryUtils.buildChildrenEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
{ depth: -1, ...pick(options, ['relations']) },
);
return entity;
}
async findAncestorsTree(
entity: CategoryEntity,
options?: FindTreeOptions,
@@ -95,19 +75,15 @@ export class CategoryRepository extends TreeRepository<CategoryEntity> {
return qb.getCount();
}
async toFlatTrees(
trees: CategoryEntity[],
depth = 0,
parent: CategoryEntity | null = null,
): Promise<CategoryEntity[]> {
const data: Omit<CategoryEntity, 'children'>[] = [];
for (const item of trees) {
item.depth = depth;
item.parent = parent;
const { children } = item;
unset(item, 'children');
data.push(item);
data.push(...(await this.toFlatTrees(children, depth + 1, item)));
async flatAncestorsTree(item: CategoryEntity) {
let data: Omit<CategoryEntity, 'children'>[] = [];
const category = await this.findAncestorsTree(item);
const { parent } = category;
unset(category, 'children');
unset(category, 'item');
data.push(item);
if (!isNil(parent)) {
data = [...(await this.flatAncestorsTree(parent)), ...data];
}
return data as CategoryEntity[];
}
@@ -1,20 +1,18 @@
import { pick, unset } from 'lodash';
import {
FindOptionsUtils,
FindTreeOptions,
SelectQueryBuilder,
TreeRepository,
TreeRepositoryUtils,
} from 'typeorm';
import { FindOptionsUtils, FindTreeOptions, SelectQueryBuilder } from 'typeorm';
import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { BaseTreeRepository } from '@/modules/database/base/tree.repository';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { QueryHook } from '@/modules/database/types';
type FindCommentTreeOptions = FindTreeOptions & {
addQuery?: (query: SelectQueryBuilder<CommentEntity>) => SelectQueryBuilder<CommentEntity>;
addQuery?: QueryHook<CommentEntity>;
};
@CustomRepository(CommentEntity)
export class CommentRepository extends TreeRepository<CommentEntity> {
export class CommentRepository extends BaseTreeRepository<CommentEntity> {
protected _qbName = 'comment';
buildBaseQB(qb: SelectQueryBuilder<CommentEntity>): SelectQueryBuilder<CommentEntity> {
return qb
.leftJoinAndSelect(`comment.parent`, 'parent')
@@ -22,14 +20,7 @@ export class CommentRepository extends TreeRepository<CommentEntity> {
.orderBy('comment.createdAt', 'DESC');
}
async findTrees(options: FindCommentTreeOptions): Promise<CommentEntity[]> {
options.relations = ['parent', 'children'];
const roots = await this.findRoots(options);
await Promise.all(roots.map((root) => this.findDescendantsTree(root, options)));
return roots;
}
findRoots(options?: FindCommentTreeOptions): Promise<CommentEntity[]> {
async findRoots(options?: FindCommentTreeOptions): Promise<CommentEntity[]> {
const { addQuery, ...rest } = options;
const escape = (val: string) => this.manager.connection.driver.escape(val);
const joinColumn = this.metadata.treeParentRelation!.joinColumns[0];
@@ -38,58 +29,19 @@ export class CommentRepository extends TreeRepository<CommentEntity> {
let qb = this.buildBaseQB(this.createQueryBuilder('comment'));
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, rest);
qb.where(`${escape('comment')}.${escape(parentPropertyName)} IS NULL`);
qb = addQuery ? addQuery(qb) : qb;
qb = addQuery ? await addQuery(qb) : qb;
return qb.getMany();
}
createDtsQueryBuilder(
async createDtsQueryBuilder(
closureTable: string,
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): SelectQueryBuilder<CommentEntity> {
): Promise<SelectQueryBuilder<CommentEntity>> {
const { addQuery } = options;
const qb = this.buildBaseQB(
super.createDescendantsQueryBuilder('comment', closureTable, entity),
);
return addQuery ? addQuery(qb) : qb;
}
async findDescendantsTree(
entity: CommentEntity,
options: FindCommentTreeOptions = {},
): Promise<CommentEntity> {
const qb: SelectQueryBuilder<CommentEntity> = this.createDtsQueryBuilder(
'treeClosure',
entity,
options,
);
FindOptionsUtils.applyOptionsToTreeQueryBuilder(qb, pick(options, ['relations', 'depth']));
const entities = await qb.getRawAndEntities();
const relationMaps = TreeRepositoryUtils.createRelationMaps(
this.manager,
this.metadata,
'comment',
entities.raw,
);
TreeRepositoryUtils.buildChildrenEntityTree(
this.metadata,
entity,
entities.entities,
relationMaps,
{ depth: -1, ...pick(options, ['relations']) },
);
return entity;
}
async toFlatTrees(trees: CommentEntity[], depth = 0): Promise<CommentEntity[]> {
const data: Omit<CommentEntity, 'children'>[] = [];
for (const item of trees) {
item.depth = depth;
const { children } = item;
unset(item, 'children');
data.push(item);
data.push(...(await this.toFlatTrees(children, depth + 1)));
}
return data as CommentEntity[];
}
}
@@ -1,21 +1,23 @@
import { Repository } from 'typeorm';
import { CommentEntity } from '@/modules/content/entities/comment.entity';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { BaseRepository } from '@/modules/database/base/repository';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
@CustomRepository(PostEntity)
export class PostRepository extends Repository<PostEntity> {
export class PostRepository extends BaseRepository<PostEntity> {
protected _qbName = 'post';
buildBaseQB() {
return this.createQueryBuilder('post')
.leftJoinAndSelect('post.category', 'category')
.leftJoinAndSelect('post.tags', 'tags')
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 = post.id');
.where(`c.post.id = ${this.qbName}.id`);
}, 'commentCount')
.loadRelationCountAndMap('post.commentCOunt', 'post.comments');
.loadRelationCountAndMap(`${this.qbName}.commentCount`, `${this.qbName}.comments`);
}
}
@@ -1,19 +1,21 @@
import { Repository } from 'typeorm';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { TagEntity } from '@/modules/content/entities/tag.entity';
import { CustomRepository } from '@/modules/database/decorators/repository.decorator';
import { BaseRepository } from '../../database/base/repository';
@CustomRepository(TagEntity)
export class TagRepository extends Repository<TagEntity> {
export class TagRepository extends BaseRepository<TagEntity> {
protected _qbName = 'tag';
buildBaseQB() {
return this.createQueryBuilder('tag')
.leftJoinAndSelect('tag.posts', 'posts')
return this.createQueryBuilder(this.qbName)
.leftJoinAndSelect(`${this.qbName}.posts`, 'posts')
.addSelect(
(query) => query.select('COUNT(p.id)', 'count').from(PostEntity, 'p'),
'postCount',
)
.orderBy('postCount', 'DESC')
.loadRelationCountAndMap('tag.postCount', 'tag.posts');
.loadRelationCountAndMap(`${this.qbName}.postCount`, `${this.qbName}.posts`);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { RouteOption, TagOption } from '@/modules/restful/types';
import * as controllers from './controllers';
import * as manageControllers from './controllers/manager';
export const createContentApi = () => {
const routes: Record<'app' | 'manager', RouteOption[]> = {
app: [
{
name: 'app.content',
path: 'content',
controllers: Object.values(controllers),
},
],
manager: [
{
name: 'manage.content',
path: 'content',
controllers: Object.values(manageControllers),
},
],
};
const tags: Record<'app' | 'manager', Array<string | TagOption>> = {
app: [
{ name: '分类查询', description: '查询分类信息' },
{ name: '标签查询', description: '查询标签信息' },
{
name: '文章操作',
description: '查询文章以及对自己的文章进行CRUD操作',
},
{
name: '评论操作',
description: '查看评论以及对自己的评论进行CRD操作',
},
],
manager: [
{ name: '分类管理', description: '管理分类信息' },
{ name: '标签管理', description: '管理标签信息' },
{ name: '文章管理', description: '管理文章信息' },
{ name: '评论管理', description: '管理评论信息' },
],
};
return { routes, tags };
};
@@ -4,6 +4,7 @@ import { deepMerge } from '@/modules/core/helpers';
export class SanitizeService {
protected config: sanitizeHtml.IOptions = {};
constructor() {
this.config = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'code']),
@@ -2,31 +2,25 @@ import { Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { EntityNotFoundError } from 'typeorm';
import {
CreateCategoryDto,
QueryCategoryDto,
UpdateCategoryDto,
} from '@/modules/content/dtos/category.dto';
import { CreateCategoryDto, UpdateCategoryDto } from '@/modules/content/dtos/category.dto';
import { CategoryEntity } from '@/modules/content/entities/category.entity';
import { CategoryRepository } from '@/modules/content/repositories/category.repository';
import { treePaginate } from '@/modules/database/utils';
import { BaseService } from '@/modules/database/base/service';
@Injectable()
export class CategoryService {
constructor(protected repository: CategoryRepository) {}
export class CategoryService extends BaseService<CategoryEntity, CategoryRepository> {
protected enableTrash = true;
constructor(protected repository: CategoryRepository) {
super(repository);
}
async findTrees() {
return this.repository.findTrees();
}
async paginate(options?: QueryCategoryDto) {
const tree = await this.findTrees();
const data = await this.repository.toFlatTrees(tree);
return treePaginate(options, data);
}
async detail(id: string) {
return this.repository.findOneOrFail({ where: { id }, relations: ['parent'] });
return this.repository.findOneOrFail({ where: { id }, relations: ['parent', 'children'] });
}
async create(data: CreateCategoryDto) {
@@ -56,21 +50,6 @@ export class CategoryService {
return item;
}
async delete(id: string) {
const item = await this.repository.findOneOrFail({
where: { id },
relations: ['parent', 'children'],
});
if (!isNil(item.children) && item.children.length > 0) {
const childrenCategories = [...item.children].map((c) => {
c.parent = item.parent;
return item;
});
await this.repository.save(childrenCategories, { reload: true });
}
return this.repository.remove(item);
}
async getParent(current?: string, parentId?: string) {
if (current === parentId) {
return undefined;
+26 -14
View File
@@ -2,7 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { EntityNotFoundError, SelectQueryBuilder } from 'typeorm';
import { EntityNotFoundError, In, SelectQueryBuilder } from 'typeorm';
import {
CreateCommentDto,
@@ -10,21 +10,25 @@ import {
QueryCommentTreeDto,
} from '@/modules/content/dtos/comment.dto';
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 { CommentRepository } from '../repositories/comment.repository';
import { PostRepository } from '../repositories/post.repository';
import { UserEntity } from '@/modules/user/entities';
import { UserRepository } from '@/modules/user/repositories';
@Injectable()
export class CommentService {
export class CommentService extends BaseService<CommentEntity, CommentRepository> {
constructor(
protected repository: CommentRepository,
protected userRepository: UserRepository,
protected postRepository: PostRepository,
) {}
) {
super(repository);
}
async findTrees(options: QueryCommentTreeDto = {}) {
return this.repository.findTrees({
addQuery: (qb) => {
addQuery: async (qb) => {
return isNil(options.post) ? qb : qb.where('post.id = :id', { id: options.post });
},
});
@@ -32,7 +36,7 @@ export class CommentService {
async paginate(options: QueryCommentDto) {
const { post, ...query } = options;
const addQuery = (qb: SelectQueryBuilder<CommentEntity>) => {
const addQuery = async (qb: SelectQueryBuilder<CommentEntity>) => {
const condition: RecordString = {};
if (!isNil(post)) {
condition.post = post;
@@ -49,7 +53,7 @@ export class CommentService {
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!');
@@ -58,13 +62,17 @@ export class CommentService {
...data,
parent,
post: await this.getPost(data.post),
author: await this.userRepository.findOneByOrFail({ id: author.id }),
});
return this.repository.findOneOrFail({
where: { id: item.id },
relations: ['parent', 'children', 'post'],
});
return this.repository.findOneOrFail({ where: { id: item.id } });
}
async delete(id: string) {
const comment = await this.repository.findOneOrFail({ where: { id: id ?? null } });
return this.repository.remove(comment);
async delete(ids: string[]) {
const comments = await this.repository.find({ where: { id: In(ids) } });
return this.repository.remove(comments);
}
protected async getPost(id: string) {
@@ -82,7 +90,7 @@ export class CommentService {
}
parent = await this.repository.findOne({
where: { id },
relations: ['parent', 'children'],
relations: ['parent', 'children', 'post'],
});
if (!parent) {
throw new EntityNotFoundError(CommentEntity, `Parent Comment ${id} not found`);
@@ -90,4 +98,8 @@ export class CommentService {
}
return parent;
}
update(data: any, ...others: any[]): Promise<CommentEntity> {
throw new Error('Method not implemented.');
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './category.service';
export * from './tag.service';
export * from './post.service';
export * from './comment.service';
export * from './search.service';
+126 -13
View File
@@ -1,17 +1,30 @@
import { Injectable } from '@nestjs/common';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { isArray, isFunction, omit } from 'lodash';
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';
import { SearchService } from '@/modules/content/services/search.service';
import type { SearchType } from '@/modules/content/types';
import { BaseService } from '@/modules/database/base/service';
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';
@@ -21,15 +34,28 @@ type FindParams = {
};
@Injectable()
export class PostService {
export class PostService extends BaseService<PostEntity, PostRepository, FindParams> {
protected enableTrash = true;
constructor(
protected repository: PostRepository,
protected categoryRepository: CategoryRepository,
protected categoryService: CategoryService,
protected tagRepository: TagRepository,
) {}
protected userRepository: UserRepository,
protected searchService?: SearchService,
protected searchType: SearchType = 'mysql',
) {
super(repository);
}
async paginate(options: QueryPostDto, callback?: QueryHook<PostEntity>) {
if (!isNil(this.searchService) && !isNil(options.search) && this.searchType === 'meili') {
return this.searchService.search(
options.search,
pick(options, ['trashed', 'page', 'limit', 'isPublished']),
);
}
const qb = await this.buildListQuery(this.repository.buildBaseQB(), options, callback);
return paginate(qb, options);
}
@@ -45,11 +71,19 @@ export class PostService {
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)
@@ -57,17 +91,30 @@ export class PostService {
: 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);
return this.detail(item.id);
const result = await this.detail(item.id);
if (!isNil(this.searchService)) {
await this.searchService.create(result);
}
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
@@ -82,15 +129,60 @@ export class PostService {
.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,
});
return this.detail(data.id);
const result = await this.detail(data.id);
if (!isNil(this.searchService)) {
await this.searchService.update([result]);
}
return result;
}
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
async delete(ids: string[], trash?: boolean) {
const items = await this.repository
.buildBaseQB()
.where('post.id IN (:...ids)', { ids })
.withDeleted()
.getMany();
let result: PostEntity[];
if (trash) {
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)),
];
if (!isNil(this.searchService)) {
await this.searchService.delete(directs.map((item) => item.id));
await this.searchService.update(softs);
}
} else {
result = await this.repository.remove(items);
if (!isNil(this.searchService)) {
await this.searchService.delete(ids);
}
}
return result;
}
async restore(ids: string[]) {
const items = await this.repository
.buildBaseQB()
.where('post.id IN (:...ids)', { ids })
.withDeleted()
.getMany();
const trashes = items.filter((item) => !isNil(item.deletedAt));
await this.searchService.update(trashes);
const trashedIds = trashes.map((item) => item.id);
if (trashedIds.length < 1) {
return [];
}
await this.repository.restore(trashedIds);
const qb = await this.buildListQuery(this.repository.buildBaseQB(), {}, async (qbuilder) =>
qbuilder.andWhereInIds(trashedIds),
);
return qb.getMany();
}
protected async buildListQuery(
@@ -98,12 +190,21 @@ export class PostService {
options: FindParams,
callback?: QueryHook<PostEntity>,
) {
const { orderBy, isPublished, category, tag } = options;
const { orderBy, isPublished, category, tag, trashed, search, author } = options;
if (typeof isPublished === 'boolean') {
isPublished
? qb.where({ publishedAt: Not(IsNull()) })
: qb.where({ publishedAt: IsNull() });
}
if (!isNil(search)) {
this.buildSearchQuery(qb, search);
}
if (trashed === SelectTrashMode.ALL || trashed === SelectTrashMode.ONLY) {
qb.withDeleted();
if (trashed === SelectTrashMode.ONLY) {
qb.where('post.deletedAt is not null');
}
}
this.queryOrderBy(qb, orderBy);
if (category) {
await this.queryByCategory(category, qb);
@@ -111,12 +212,24 @@ export class PostService {
if (tag) {
qb.where('tags.id = :id', { id: tag });
}
if (author) {
qb.where('author.id = :id', { id: author });
}
if (callback) {
return callback(qb);
}
return qb;
}
protected buildSearchQuery(qb: SelectQueryBuilder<PostEntity>, search: string) {
qb.orWhere('title LIKE :search', { search: `%${search}%` })
.orWhere('summary LIKE :search', { search: `%${search}%` })
.orWhere('body LIKE :search', { search: `%${search}%` })
.orWhere('category.name LIKE :search', { search: `%${search}%` })
.orWhere('tags.name LIKE :search', { search: `%${search}%` });
return qb;
}
protected queryOrderBy(qb: SelectQueryBuilder<PostEntity>, orderBy?: PostOrder) {
switch (orderBy) {
case PostOrder.CREATED:
@@ -142,6 +255,6 @@ export class PostService {
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 });
}
}
@@ -0,0 +1,95 @@
import { ForbiddenException, Injectable, OnModuleInit } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { MeiliSearch } from 'meilisearch';
import { PostEntity } from '@/modules/content/entities';
import {
CategoryRepository,
CommentRepository,
PostRepository,
} from '@/modules/content/repositories';
import { SearchOption } from '@/modules/content/types';
import { getSearchData, getSearchItem } from '@/modules/content/utils';
import { SelectTrashMode } from '@/modules/database/constants';
import { MeiliService } from '@/modules/meilisearch/meili.service';
@Injectable()
export class SearchService implements OnModuleInit {
private index = 'content';
protected client: MeiliSearch;
constructor(
protected meiliService: MeiliService,
protected categoryRepository: CategoryRepository,
protected commentRepository: CommentRepository,
protected postRepository: PostRepository,
) {
this.client = this.meiliService.getClient();
}
async onModuleInit(): Promise<any> {
await this.client.deleteIndex(this.index);
this.client.index(this.index).updateFilterableAttributes(['deletedAt', 'publishedAt']);
this.client.index(this.index).updateSortableAttributes(['updatedAt', 'commentCount']);
const posts = await this.postRepository.buildBaseQB().withDeleted().getMany();
await this.client
.index(this.index)
.addDocuments(
await getSearchData(posts, this.categoryRepository, this.commentRepository),
);
}
getClient() {
if (isNil(this.client)) {
throw new ForbiddenException('Has no meili search client!');
}
return this.client;
}
async search(text: string, param: SearchOption = {}): Promise<any> {
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'];
if (option.trashed === SelectTrashMode.ALL) {
filter = [];
} else if (option.trashed === SelectTrashMode.ONLY) {
filter = ['deletedAt IS NOT NULL'];
}
if (option.isPublished) {
filter.push('publishedAt IS NOT NULL');
}
const result = await this.client
.index(this.index)
.search(text, { page, limit, sort: ['updatedAt:desc', 'commentCount:desc'], filter });
return {
items: result.hits,
currentPage: result.page,
perPage: result.hitsPerPage,
totalItems: result.estimatedTotalHits,
itemCount: result.totalHits,
...omit(result, ['hits', 'page', 'hitsPerPage', 'estimatedTotalHits', 'totalHits']),
};
}
async create(post: PostEntity) {
return this.getClient()
.index(this.index)
.addDocuments(
await getSearchItem(this.categoryRepository, this.commentRepository, post),
);
}
async update(posts: PostEntity[]) {
return this.getClient()
.index(this.index)
.updateDocuments(
await getSearchData(posts, this.categoryRepository, this.commentRepository),
);
}
async delete(ids: string[]) {
return this.getClient().index(this.index).deleteDocuments(ids);
}
}
+8 -12
View File
@@ -1,17 +1,18 @@
import { Injectable } from '@nestjs/common';
import { omit } from 'lodash';
import { CreateTagDto, QueryTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
import { CreateTagDto, UpdateTagDto } from '@/modules/content/dtos/tag.dto';
import { TagRepository } from '@/modules/content/repositories/tag.repository';
import { paginate } from '@/modules/database/utils';
import { BaseService } from '@/modules/database/base/service';
import { TagEntity } from '../entities';
@Injectable()
export class TagService {
constructor(protected repository: TagRepository) {}
export class TagService extends BaseService<TagEntity, TagRepository> {
protected enableTrash = true;
async paginate(options: QueryTagDto) {
const qb = this.repository.buildBaseQB();
return paginate(qb, options);
constructor(protected repository: TagRepository) {
super(repository);
}
async detail(id: string) {
@@ -29,9 +30,4 @@ export class TagService {
await this.repository.update(data.id, omit(data, ['id']));
return this.detail(data.id);
}
async delete(id: string) {
const item = await this.repository.findOneByOrFail({ id });
return this.repository.remove(item);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './post.subscriber';
@@ -1,26 +1,33 @@
import { DataSource, EventSubscriber } from 'typeorm';
import { isNil } from 'lodash';
import { DataSource, EventSubscriber, ObjectType } from 'typeorm';
import { Configure } from '@/modules/config/configure';
import { PostBodyType } from '@/modules/content/constants';
import { PostEntity } from '@/modules/content/entities/post.entity';
import { PostRepository } from '@/modules/content/repositories/post.repository';
import { SanitizeService } from '@/modules/content/services/SanitizeService';
import { BaseSubscriber } from '@/modules/database/base/subscriber';
@EventSubscriber()
export class PostSubscriber {
export class PostSubscriber extends BaseSubscriber<PostEntity> {
protected entity: ObjectType<PostEntity> = PostEntity;
constructor(
protected dataSource: DataSource,
protected sanitizeService: SanitizeService,
protected postRepository: PostRepository,
protected _configure: Configure,
) {
dataSource.subscribers.push(this);
super(dataSource, _configure);
}
listenTo() {
return PostEntity;
get configure(): Configure {
return this._configure;
}
async afterLoad(entity: PostEntity) {
if (entity.type === PostBodyType.HTML) {
entity.body = this.sanitizeService.sanitize(entity.body);
const sanitizeService = (await this.configure.get('content.htmlEnabled'))
? this.container.get(SanitizeService)
: undefined;
if (!isNil(sanitizeService) && entity.type === PostBodyType.HTML) {
entity.body = sanitizeService.sanitize(entity.body);
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { SelectTrashMode } from '@/modules/database/constants';
export type SearchType = 'mysql' | 'meili';
export interface ContentConfig {
searchType: SearchType;
htmlEnabled: boolean;
}
export interface SearchOption {
trashed?: SelectTrashMode;
isPublished?: boolean;
page?: number;
limit?: number;
}
+53
View File
@@ -0,0 +1,53 @@
import { instanceToPlain } from 'class-transformer';
import { isNil, pick } from 'lodash';
import { PostEntity } from '@/modules/content/entities';
import { CategoryRepository, CommentRepository } from '@/modules/content/repositories';
export async function getSearchItem(
categoryRepository: CategoryRepository,
commentRepository: CommentRepository,
post: PostEntity,
) {
const categories = isNil(post.category)
? []
: (await categoryRepository.flatAncestorsTree(post.category)).map((item) => ({
id: item.id,
name: item.name,
}));
const comments = (
await commentRepository.find({
relations: ['post'],
where: { post: { id: post.id } },
})
).map((item) => ({ id: item.id, name: item.body }));
return [
{
...pick(instanceToPlain(post), [
'id',
'title',
'body',
'summary',
'commentCount',
'deletedAt',
'publishedAt',
'createdAt',
'updatedAt',
]),
categories,
comments,
tags: post.tags.map((item) => ({ id: item.id, name: item.name })),
},
];
}
export const getSearchData = async (
posts: PostEntity[],
categoryRepository: CategoryRepository,
commentRepository: CommentRepository,
) =>
(
await Promise.all(
posts.map((post) => getSearchItem(categoryRepository, commentRepository, post)),
)
).reduce((o, n) => [...o, ...n], []);
@@ -0,0 +1,52 @@
import { spawn } from 'node:child_process';
import { exit } from 'process';
import { Arguments } from 'yargs';
import { getCLIConfig } from '@/modules/core/commands/helpers/config';
import { BuildCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const createBuildCommand: CommandItem<any, BuildCommandArguments> = async (app) => ({
command: ['build', 'b'],
describe: 'Build application by nest cli.',
builder: {
nestConfig: {
type: 'string',
alias: 'n',
describe: 'nest cli config file path.',
default: 'nest-cli.json',
},
tsConfig: {
type: 'string',
alias: 't',
describe: 'typescript config file path.',
default: 'tsconfig.build.json',
},
watch: {
type: 'boolean',
alias: 'w',
describe: ' Run in watch mode (live-reload).',
default: false,
},
preserveWatchOutput: {
type: 'boolean',
alias: 'po',
describe: 'Use "preserveWatchOutput" option when using tsc watch mode',
default: false,
},
},
handler: async (args: Arguments<BuildCommandArguments>) => {
const config = getCLIConfig(args.tsConfig, args.nestConfig);
const params = ['build', '-c', args.nestConfig, '-p', args.tsConfig];
if (args.watch) {
params.push('-w');
}
if (args.preserveWatchOutput) {
params.push('po');
}
const child = spawn(config.paths.nest, params, config.subprocess.node);
child.on('exit', () => exit());
},
});
+21
View File
@@ -0,0 +1,21 @@
import { DemoCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const DemoCommand: CommandItem<any, DemoCommandArguments> = async (app) => ({
command: ['demo', 'd'],
describe: 'a demo command',
handler: async (args: DemoCommandArguments) => {
const { configure } = app;
const appName = await configure.get<string>('app.name');
const sleep = args.sleep ? ' will to sleep' : '';
console.log(`just a demo command,app ${appName} ${sleep}`);
},
builder: {
sleep: {
type: 'boolean',
alias: 's',
describe: 'App will sleep ?',
default: false,
},
},
});
@@ -0,0 +1,97 @@
import { join } from 'path';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ActionOnFile, AssetEntry } from '@nestjs/cli/lib/configuration';
import chokidar, { FSWatcher } from 'chokidar';
import { get } from 'lodash';
import { CLIConfig } from '@/modules/core/commands/types';
import { toBoolean } from '@/modules/core/helpers';
export class Asset {
private watchAssetsKeyValue: { [key: string]: boolean } = {};
private watchers: FSWatcher[] = [];
private actionInProgress = false;
closeWatchers() {
const timeout = 500;
const closeFn = () => {
if (this.actionInProgress) {
this.actionInProgress = false;
setTimeout(closeFn, timeout);
} else {
this.watchers.forEach((watch) => watch.close());
}
};
setTimeout(closeFn, timeout);
}
watchAssets(config: CLIConfig, codePath: string, changer: () => void) {
const assets = get(config.options.nest, 'compilerOptions.assets', []) as AssetEntry[];
if (assets.length <= 0) {
return;
}
try {
const isWatchEnabled = toBoolean(get(config, 'watchAssets', 'src'));
const filesToWatch = assets.map<AssetEntry>((item) => {
if (typeof item === 'string') {
return {
glob: join(codePath, item),
};
}
return {
glob: join(codePath, item.include!),
exclude: item.exclude ? join(codePath, item.exclude) : undefined,
flat: item.flat,
watchAssets: item.watchAssets,
};
});
for (const file of filesToWatch) {
const option: ActionOnFile = {
action: 'change',
item: file,
path: '',
sourceRoot: codePath,
watchAssetsMode: isWatchEnabled,
};
const watcher = chokidar
.watch(file.glob, { ignored: file.exclude })
.on('add', (path) =>
this.actionOnFIle({ ...option, path, action: 'change' }, changer),
)
.on('change', (path) =>
this.actionOnFIle({ ...option, path, action: 'change' }, changer),
)
.on('unlink', (path) =>
this.actionOnFIle({ ...option, path, action: 'unlink' }, changer),
);
this.watchers.push(watcher);
}
} catch (e) {
throw new Error(
`An error occurred during the assets copying process. ${(e as any).message}`,
);
}
}
protected actionOnFIle(option: ActionOnFile, changer: () => void) {
const { action, item, path, watchAssetsMode } = option;
const isWatchEnabled = watchAssetsMode || item.watchAssets;
if (!isWatchEnabled && this.watchAssetsKeyValue[path]) {
return;
}
this.watchAssetsKeyValue[path] = true;
this.actionInProgress = true;
if (action === 'change') {
changer();
}
}
}
+113
View File
@@ -0,0 +1,113 @@
/* eslint-disable import/no-extraneous-dependencies */
import { join, resolve } from 'path';
import { exit } from 'process';
import { Configuration as NestCLIConfig } from '@nestjs/cli/lib/configuration';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { existsSync, readFileSync } from 'fs-extra';
import { get, omit } from 'lodash';
import { StartOptions } from 'pm2';
import ts from 'typescript';
import { Configure } from '@/modules/config/configure';
import { CLIConfig, Pm2Option } from '@/modules/core/commands/types';
import { deepMerge, panic } from '@/modules/core/helpers';
import { AppConfig } from '@/modules/core/types';
const cwdPath = resolve(__dirname, '../../../../..');
export function getCLIConfig(
tsConfigFile: string,
nestConfigFile: string,
tsEntryFile?: string,
): CLIConfig {
let tsConfig: ts.CompilerOptions = {};
const tsConfigPath = join(cwdPath, tsConfigFile);
if (!existsSync(tsConfigPath)) {
panic(`ts config file ${tsConfigPath} not exists!`);
}
try {
const allTsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
tsConfig = get(allTsConfig, 'compilerOptions', {});
} catch (error) {
panic({ error, message: 'get ts config file failed.' });
}
let nestConfig: NestCLIConfig = {};
const nestConfigPath = join(cwdPath, nestConfigFile);
if (!existsSync(nestConfigPath)) {
panic(`ts config file ${nestConfigPath} not exists!`);
}
try {
nestConfig = JSON.parse(readFileSync(nestConfigPath, 'utf8'));
} catch (error) {
panic({ error, message: 'get nest config file failed.' });
}
const dist = get(tsConfig, 'outDir', 'dist');
const src = get(nestConfig, 'sourceRoot', 'src');
const homeDir = process.env.HOME;
const paths = {
cwd: cwdPath,
dist,
src,
js: join(dist, nestConfig.entryFile ?? 'main.js'),
ts: join(src, tsEntryFile ?? 'main.ts'),
bun: `${homeDir}/.bun/bin/bun`,
nest: './node_modules/@nestjs/cli/bin/nest.js',
};
return {
options: { ts: tsConfig, nest: nestConfig },
paths,
subprocess: {
bun: {
cwd: cwdPath,
stdout: 'inherit',
env: process.env,
onExit: (proc) => {
proc.kill();
if (!isNil(proc.exitCode)) {
exit(0);
}
},
},
node: {
cwd: cwdPath,
env: process.env,
stdio: 'inherit',
},
},
};
}
export async function getPm2Config(
configure: Configure,
option: Pm2Option,
config: CLIConfig,
script: string,
): Promise<StartOptions> {
const { name, pm2: customConfig = {} } = await configure.get<AppConfig>('app');
const defaultConfig: StartOptions = {
name,
cwd: cwdPath,
script,
args: option.command,
autorestart: true,
watch: option.watch,
ignore_watch: ['node_modules'],
env: process.env,
exec_mode: 'fork',
interpreter: config.paths.bun,
};
return deepMerge(
defaultConfig,
omit(customConfig, ['name', 'cwd', 'script', 'args', 'watch', 'interpreter']),
'replace',
);
}
+125
View File
@@ -0,0 +1,125 @@
import { isNil } from '@nestjs/common/utils/shared.utils';
import { Subprocess } from 'bun';
import chalk from 'chalk';
import { pick } from 'lodash';
import pm2 from 'pm2';
import { Arguments } from 'yargs';
import { Configure } from '@/modules/config/configure';
import { Asset } from '@/modules/core/commands/helpers/asset';
import { getPm2Config } from '@/modules/core/commands/helpers/config';
import { generateSwaggerMetadata } from '@/modules/core/commands/helpers/swagger';
import { CLIConfig, StartCommandArguments } from '@/modules/core/commands/types';
import { AppConfig } from '@/modules/core/types';
export async function start(
args: Arguments<StartCommandArguments>,
config: CLIConfig,
): Promise<void> {
console.log('command start...');
const script = args.typescript ? config.paths.ts : config.paths.js;
const params = [config.paths.bun, 'run'];
if (args.watch) {
params.push('--watch');
}
if (args.debug) {
const inspectFlag =
typeof args.debug === 'string' ? `--inspect=${args.debug}` : '--inspect';
params.push(inspectFlag);
}
if (args.typescript) {
generateSwaggerMetadata(args, config, false);
}
params.push(script);
let child: Subprocess;
if (args.watch) {
const asset = new Asset();
const restart = () => {
if (!isNil(child)) {
child.kill();
}
child = Bun.spawn(params, config.subprocess.bun);
};
restart();
asset.watchAssets(config, config.paths.cwd, restart);
process.on('exit', () => {
child.kill();
asset.closeWatchers();
process.exit(0);
});
} else {
Bun.spawn(params, {
...config.subprocess.bun,
onExit(proc) {
proc.kill();
process.exit(0);
},
});
}
}
export async function startPM2(
configure: Configure,
args: Arguments<StartCommandArguments>,
config: CLIConfig,
): Promise<void> {
console.log('command startPM2...');
const { name } = await configure.get<AppConfig>('app');
const script = args.typescript ? config.paths.ts : config.paths.js;
const pm2config = await getPm2Config(
configure,
{ command: 'start', ...pick(args, ['watch', 'typescript']) },
config,
script,
);
if (pm2config.exec_mode === 'cluster' && args.typescript) {
console.log(
chalk.yellowBright(
'Cannot directly use bun to run ts code in cluster mode, so it will automatically change to fork mode.',
),
);
console.log();
console.log(
chalk.bgCyanBright(
chalk.blackBright(
'If you really need the app to be started in cluster mode, be sure to compile it into js first, and then add the --no-ts arg when running',
),
),
);
console.log();
pm2config.exec_mode = 'fork';
}
const connectCallback = (error?: any) => {
if (!isNil(error)) {
console.error(error);
process.exit(2);
}
};
const startCallback = (error?: any) => {
if (!isNil(error)) {
console.error(error);
process.exit(1);
}
pm2.disconnect();
};
const restartCallback = (error?: any) => {
if (isNil(error)) {
pm2.disconnect();
} else {
pm2.start(pm2config, (err) => startCallback(err));
}
};
pm2.connect((err: any) => {
connectCallback(err);
generateSwaggerMetadata(args, config, false);
args.restart
? pm2.restart(name, restartCallback)
: pm2.start(pm2config, (e) => startCallback(e));
});
}
@@ -0,0 +1,39 @@
/* eslint-disable import/no-extraneous-dependencies */
import { join } from 'path';
import { PluginMetadataGenerator } from '@nestjs/cli/lib/compiler/plugins/plugin-metadata-generator';
import { PluginOptions } from '@nestjs/cli/lib/configuration';
import { ReadonlyVisitor } from '@nestjs/swagger/dist/plugin';
import { get, isNil } from 'lodash';
import { Arguments } from 'yargs';
import { CLIConfig, StartCommandArguments } from '@/modules/core/commands/types';
export function generateSwaggerMetadata(
args: Arguments<StartCommandArguments>,
config: CLIConfig,
watch: boolean,
) {
const cliPlugins = get(config.options.nest, 'compilerOptions.plugins', []) as (
| string
| RecordAny
)[];
const swaggerPlugin = cliPlugins.find(
(item) => item === '@nest/swagger' || (item as any).name === '@nest/swagger',
);
if (!isNil(swaggerPlugin) && args.typescript) {
const srcPath = join(config.paths.cwd, config.paths.src);
const generator = new PluginMetadataGenerator();
let swaggerPluginOption: PluginOptions;
if (typeof swaggerPlugin !== 'string' && 'options' in swaggerPlugin) {
swaggerPluginOption = swaggerPlugin.options;
}
generator.generate({
visitors: [new ReadonlyVisitor({ ...swaggerPluginOption, pathToSource: srcPath })],
outputDir: srcPath,
watch,
tsconfigPath: args.tsConfig,
printDiagnostics: false,
});
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './demo.command';
export * from './build.command';
export * from './start.command';
@@ -0,0 +1,72 @@
import { Arguments } from 'yargs';
import { getCLIConfig } from '@/modules/core/commands/helpers/config';
import { start, startPM2 } from '@/modules/core/commands/helpers/start';
import { StartCommandArguments } from '@/modules/core/commands/types';
import { CommandItem } from '@/modules/core/types';
export const createStartCommand: CommandItem<any, StartCommandArguments> = async (app) => ({
command: ['start', 's'],
describe: 'Start app',
builder: {
nestConfig: {
type: 'string',
alias: 'n',
describe: 'nest cli config file path.',
default: 'nest-cli.json',
},
tsConfig: {
type: 'string',
alias: 't',
describe: 'typescript config file path.',
default: 'tsconfig.build.json',
},
entry: {
type: 'string',
alias: 'e',
describe:
'Specify entry file for ts runner, you can specify js entry file in nest-cli.json by entryFile.',
default: 'main.ts',
},
prod: {
type: 'boolean',
alias: 'p',
describe: 'Start app in production by pm2.',
default: false,
},
restart: {
type: 'boolean',
alias: 'r',
describe: 'Restart app(only pm2),pm2 will auto run start if process not exists.',
default: false,
},
typescript: {
type: 'boolean',
alias: 'ts',
describe: 'Run the .ts file directly.',
default: true,
},
watch: {
type: 'boolean',
alias: 'w',
describe: ' Run in watch mode (live-reload).',
default: false,
},
debug: {
type: 'boolean',
alias: 'd',
describe: 'Whether to enable debug mode, only valid for non-production environments',
default: false,
},
},
handler: async (args: Arguments<StartCommandArguments>) => {
console.log('createStartCommand handler start');
const { configure } = app;
const config = getCLIConfig(args.tsConfig, args.nestConfig, args.entry);
if (args.prod || args.restart) {
await startPM2(configure, args, config);
} else {
await start(args, config);
}
},
});
+64
View File
@@ -0,0 +1,64 @@
/* eslint-disable import/no-extraneous-dependencies */
import { SpawnOptions as NodeSpawnOptions } from 'child_process';
import { Configuration as NestCLIConfig } from '@nestjs/cli/lib/configuration';
import type { SpawnOptions as BunSpawnOptions } from 'bun';
import ts from 'typescript';
export type DemoCommandArguments = {
sleep?: boolean;
};
export interface CLIConfig {
options: {
ts: ts.CompilerOptions;
nest: NestCLIConfig;
};
paths: Record<'cwd' | 'dist' | 'src' | 'js' | 'ts' | 'bun' | 'nest', string>;
subprocess: {
bun: BunSpawnOptions.OptionsObject<any, any, any>;
node: NodeSpawnOptions;
};
}
export type StartCommandArguments = {
/**
* nest-cli.json的文件路径(相对于当前运行目录)
*/
nestConfig?: string;
/**
* 用于编译和运行的tsconfig.build.json的文件路径(相对于当前运行目录)
*/
tsConfig?: string;
/**
* 使用直接运行TS文件的入口文件,默认为main.ts. 如果是运行js文件,则通过nest-cli.json的entryFile指定
*/
entry?: string;
/**
* 是否使用PM2后台静默启动生产环境
*/
prod?: boolean;
/**
* 使用直接运行TS文件,这个配置只针对生产环境下是否通过
*/
typescript?: boolean;
/**
* 是否监控,所有环境都可以使用(但在非PM2启动的生产环境下此选项无效)
*/
watch?: boolean;
/**
* 是否开启debug模式,只对非生产环境有效
*/
debug?: boolean | string;
/**
* 是否重启应用(PM2进程)
*/
restart?: boolean;
};
export type Pm2Option = Pick<StartCommandArguments, 'typescript' | 'watch'> & { command: string };
export type BuildCommandArguments = Pick<StartCommandArguments, 'tsConfig' | 'nestConfig'> & {
watch?: string;
preserveWatchOutput?: boolean;
};
+31
View File
@@ -0,0 +1,31 @@
import { isNil, toNumber } from 'lodash';
import { Configure } from '../config/configure';
import { ConfigureFactory, ConfigureRegister } from '../config/types';
import { getRandomString, toBoolean } from './helpers';
import { AppConfig } from './types';
export const getDefaultAppConfig = (configure: Configure) => ({
name: configure.env.get('APP_NAME', getRandomString()),
host: configure.env.get('APP_HOST', '127.0.0.1'),
port: configure.env.get('APP_PORT', (v) => toNumber(v), 3000),
https: configure.env.get('APP_SSL', (v) => toBoolean(v), false),
locale: configure.env.get('APP_LOCALE', 'zh_CN'),
fallbackLocale: configure.env.get('APP_FALLBACK_LOCALE', 'en'),
timezone: configure.env.get('APP_TIMEZONE', 'Asia/Shanghai'),
});
export const createAppConfig: (
register: ConfigureRegister<RePartial<AppConfig>>,
) => ConfigureFactory<AppConfig> = (register) => ({
register,
defaultRegister: (configure) => getDefaultAppConfig(configure),
hook: (configure: Configure, value) => {
if (isNil(value.url)) {
value.url = `${value.https ? 'https:' : 'http:'}//${value.host}:${value.port}`;
}
return value;
},
});
@@ -13,6 +13,7 @@ export class MatchConstraint implements ValidatorConstraintInterface {
const relatedValue = (validationArguments.object as any)[relatedProperty];
return (value === relatedValue) !== reverse;
}
defaultMessage?(validationArguments?: ValidationArguments): string {
const [relatedProperty, reverse] = validationArguments.constraints;
return `${relatedProperty} and ${validationArguments.property} ${
@@ -21,7 +22,7 @@ export class MatchConstraint implements ValidatorConstraintInterface {
}
}
export function isMatch(
export function IsMatch(
relatedProperty: string,
reverse = false,
validationOptions?: ValidationOptions,
@@ -1,8 +1,8 @@
import {
ValidationArguments,
ValidatorConstraintInterface,
ValidationOptions,
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraintInterface,
} from 'class-validator';
type ModelType = 1 | 2 | 3 | 4 | 5;
@@ -30,6 +30,7 @@ export class PasswordConstraint implements ValidatorConstraintInterface {
return /\d/.test(value) && /[A-Za-z]/.test(value);
}
}
defaultMessage?(validationArguments?: ValidationArguments): string {
return "($value) 's format error";
}
@@ -1,5 +1,4 @@
import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator';
// eslint-disable-next-line import/no-extraneous-dependencies
import { isMobilePhone, IsMobilePhoneOptions, MobilePhoneLocale } from 'validator';
export function isMatchPhone(
+2
View File
@@ -1 +1,3 @@
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';
export const ADDTIONAL_RELATIONSHIPS = 'addtional_relationships';
+66 -4
View File
@@ -1,13 +1,75 @@
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';
@Module({})
export class CoreModule {
static forRoot(): DynamicModule {
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,
});
}
}
}
+111
View File
@@ -0,0 +1,111 @@
import { BadGatewayException, Global, Module, ModuleMetadata, Type } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { useContainer } from 'class-validator';
import { isNil, omit } from 'lodash';
import { ConfigModule } from '@/modules/config/config.module';
import { Configure } from '@/modules/config/configure';
import { DEFAULT_VALIDATION_CONFIG } from '@/modules/content/constants';
import { createCommands } from '@/modules/core/helpers/command';
import { CoreModule } from '../core.module';
import { AppFilter } from '../providers/app.filter';
import { AppInterceptor } from '../providers/app.interceptor';
import { AppPipe } from '../providers/app.pipe';
import { App, AppConfig, CreateOptions } from '../types';
import { CreateModule } from './utils';
export const app: App = { configure: new Configure(), commands: [] };
export const createApp = (options: CreateOptions) => async (): Promise<App> => {
const { config, builder } = options;
await app.configure.initialize(config.factories, config.storage);
if (!app.configure.has('app')) {
throw new BadGatewayException('App config not exists');
}
const BootModule = await createBootModule(app.configure, options);
app.container = await builder({ configure: app.configure, BootModule });
useContainer(app.container.select(BootModule), { fallbackOnErrors: true });
app.commands = await createCommands(options.commands, app as Required<App>);
return app;
};
export async function createBootModule(
configure: Configure,
options: Pick<CreateOptions, 'globals' | 'providers' | 'modules'>,
): Promise<Type<any>> {
const { globals = {}, providers = [] } = options;
const modules = await options.modules(configure);
const imports: ModuleMetadata['imports'] = (
await Promise.all([
...modules,
ConfigModule.forRoot(configure),
await CoreModule.forRoot(configure),
])
).map((item) => {
if ('module' in item) {
const meta = omit(item, ['module', 'global']);
Module(meta)(item.module);
if (item.global) {
Global()(item.module);
}
return item.module;
}
return item;
});
if (globals.pipe !== null) {
const pipe = globals.pipe
? globals.pipe(configure)
: new AppPipe(DEFAULT_VALIDATION_CONFIG);
providers.push({ provide: APP_PIPE, useValue: pipe });
}
if (globals.interceptor !== null) {
providers.push({
provide: APP_INTERCEPTOR,
useClass: globals.interceptor ?? AppInterceptor,
});
}
if (globals.filter !== null) {
providers.push({
provide: APP_FILTER,
useClass: AppFilter,
});
}
if (!isNil(globals.guard)) {
providers.push({
provide: APP_GUARD,
useClass: globals.guard,
});
}
return CreateModule('BootModule', () => ({
imports,
providers,
}));
}
export async function startApp(
creator: () => Promise<App>,
listened: (app: App, startTime: Date) => () => Promise<void>,
) {
const startTime = new Date();
const { container, configure } = await creator();
app.container = container;
app.configure = configure;
const { port, host } = await configure.get<AppConfig>('app');
await container.listen(port, host, listened(app, startTime));
}
+55
View File
@@ -0,0 +1,55 @@
import chalk from 'chalk';
import yargs, { Arguments, CommandModule } from 'yargs';
import { hideBin } from 'yargs/helpers';
import * as coreCommands from '../commands';
import { App, CommandCollection } from '../types';
export async function buildCli(creator: () => Promise<App>) {
console.log('buildCli start');
const app = await creator();
const bin = yargs(hideBin(process.argv));
app.commands.forEach((cmd) => {
bin.command(cmd);
});
bin.usage('Usage: $0 <command> [args]')
.scriptName('cli')
.demandCommand(1, '')
.fail((msg, err, y) => {
if (!msg && !err) {
bin.showHelp();
process.exit();
}
if (msg) {
console.error(chalk.red(msg));
}
if (err) {
console.error(chalk.red(err.message));
}
process.exit();
})
.strict()
.alias('v', 'version')
.help('h')
.alias('h', 'help')
.parse();
}
export async function createCommands(
factory: () => CommandCollection,
app: Required<App>,
): Promise<CommandModule<any, any>[]> {
const collection: CommandCollection = [...factory(), ...Object.values(coreCommands)];
const commands = await Promise.all(collection.map(async (command) => command(app)));
return commands.map((command) => ({
...command,
handler: async (args: Arguments<RecordAny>) => {
await app.container.close();
await command.handler(args);
if (command.instant) {
process.exit();
}
},
}));
}
+30
View File
@@ -0,0 +1,30 @@
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import dayOfYear from 'dayjs/plugin/dayOfYear';
import localeData from 'dayjs/plugin/localeData';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { Configure } from '@/modules/config/configure';
import { AppConfig, TimeOptions } from '@/modules/core/types';
dayjs.extend(localeData);
dayjs.extend(utc);
dayjs.extend(advancedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(dayOfYear);
dayjs.extend(timezone);
/**
* 获取一个dayjs时间对象
* @param configure
* @param options
*/
export async function getTime(configure: Configure, options?: TimeOptions) {
const { date, format, locale, strict, zonetime } = options ?? {};
const config = await configure.get<AppConfig>('app');
const now = dayjs(date, format, locale ?? config.locale, strict).clone();
return now.tz(zonetime ?? config.timezone);
}
+94 -1
View File
@@ -1,6 +1,10 @@
import { Module, ModuleMetadata, Type } from '@nestjs/common';
import chalk from 'chalk';
import deepmerge from 'deepmerge';
import { isNil } from 'lodash';
import { PanicOption } from '../types';
export function toBoolean(value?: string | boolean): boolean {
if (isNil(value)) {
return false;
@@ -10,7 +14,7 @@ export function toBoolean(value?: string | boolean): boolean {
}
try {
return JSON.parse(value.toLowerCase());
} catch (error) {
} catch {
return value as unknown as boolean;
}
}
@@ -32,3 +36,92 @@ export const deepMerge = <T, P>(
}
return deepmerge(x, y, options) as P extends T ? T : T & P;
};
export function isAsyncFunction<T, P extends Array<any>>(
callback: (...args: P) => T | Promise<T>,
): callback is (...args: P) => Promise<T> {
const AsyncFunction = (async () => {}).constructor;
return callback instanceof AsyncFunction;
}
export function CreateModule(
target: string | Type<any>,
metaSetter: () => ModuleMetadata = () => ({}),
): Type<any> {
let ModuleClass: Type<any>;
if (typeof target === 'string') {
ModuleClass = class {};
Object.defineProperty(ModuleClass, 'name', { value: target });
} else {
ModuleClass = target;
}
Module(metaSetter())(ModuleClass);
return ModuleClass;
}
export const getRandomString = (length = 10) => {
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const totalLength = characters.length;
for (let index = 0; index < length; index++) {
result += characters.charAt(Math.floor(Math.random() * totalLength));
}
return result;
};
export async function panic(option: PanicOption | string) {
console.log();
if (typeof option === 'string') {
console.log(chalk.red(`\n❌ ${option}`));
process.exit(1);
}
const { error, message, spinner, exit = true } = option;
if (isNil(error)) {
isNil(spinner)
? console.log(chalk.red(`\n❌ ${message}`))
: spinner.succeed(chalk.red(`\n❌ ${message}`));
} else {
isNil(spinner) ? console.log(chalk.red(error)) : spinner.fail(chalk.red(error));
}
if (exit) {
process.exit(1);
}
}
/**
* 获取小于N的随机整数
* @param count
*/
export function getRandomIndex(count: number) {
return Math.floor(Math.random() * count);
}
/**
* 从列表中获取一个随机项
* @param list
*/
export function getRandomItemData<T extends RecordAny>(list: T[]) {
if (isNil(list) || list.length === 0) {
throw new Error('list is empty');
}
return list[getRandomIndex(list.length)];
}
/**
* 从列表中获取多个随机项组成一个新列表
* @param list
*/
export function getRandomListData<T extends RecordAny>(list: T[]) {
if (isNil(list) || list.length === 0) {
throw new Error('list is empty');
}
const result: T[] = [];
for (let i = 0; i < getRandomIndex(list.length); i++) {
const random = getRandomItemData(list);
if (!result.find((p) => p.id === random.id)) {
result.push(random);
}
}
return result.length === 0 ? [list[0]] : result;
}

Some files were not shown because too many files have changed in this diff Show More