diff --git a/.gitignore b/.gitignore index bc4414db..b057231d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,4 @@ pnpm-debug.log* *.sln *.sw? -.history \ No newline at end of file +.history diff --git a/Dockerfile b/Dockerfile index b9d76658..2f0396b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,9 @@ RUN npm config set registry https://registry.npmjs.org/ # 安装项目依赖 RUN cd /xiaoju-survey/web && npm install && npm run build -RUN cd /xiaoju-survey && cp -af ./web/dist/* ./server/src/apps/ui/public/ +RUN cd /xiaoju-survey && cp -af ./web/dist/* ./server/public/ -RUN cd /xiaoju-survey/server && npm install && npm run copy && npm run build +RUN cd /xiaoju-survey/server && npm install && npm run build # 暴露端口 需要跟server的port一致 EXPOSE 3000 diff --git a/docker-compose.yaml b/docker-compose.yaml index 00e22879..7eaceee8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,11 +16,15 @@ services: - xiaoju-survey xiaoju-survey: - image: "xiaojusurvey/xiaoju-survey:1.0.0" + image: "xiaojusurvey/xiaoju-survey:1.0.3" container_name: xiaoju-survey restart: always ports: - "8080:3000" # API端口 + environment: + XIAOJU_SURVEY_MONGO_URL: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@xiaoju-survey-mongo:27017 # docker-compose 会根据容器名称自动处理 + XIAOJU_SURVEY_JWT_SECRET: surveyEngineJwtSecret + XIAOJU_SURVEY_JWT_EXPIRES_IN: 8h links: - mongo:mongo depends_on: diff --git a/docker-run.sh b/docker-run.sh index 53f1f058..347a7dc3 100644 --- a/docker-run.sh +++ b/docker-run.sh @@ -1,3 +1,3 @@ #! /bin/bash cd /xiaoju-survey/server -npm run start \ No newline at end of file +npm run start:prod \ No newline at end of file diff --git a/server/.env b/server/.env new file mode 100644 index 00000000..eda5c17c --- /dev/null +++ b/server/.env @@ -0,0 +1,11 @@ +XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey +XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017 +XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey +XIAOJU_SURVEY_MONGO_AUTH_SOURCE= + +XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa + +XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret +XIAOJU_SURVEY_JWT_EXPIRES_IN=8h + +XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log \ No newline at end of file diff --git a/server/.env.development b/server/.env.development new file mode 100644 index 00000000..e69de29b diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index 4d51295f..00000000 --- a/server/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# mongo -XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017 -XIAOJU_SURVER_MONGO_DBNAME=xiaojuSurvey - -# session -# 8 * 3600 * 1000 -XIAOJU_SURVEY_SESSION_EXPIRE_TIME=28800000 - -# encrypt -XIAOJU_SURVEY_ENCRYPT_TYPE=aes -XIAOJU_SURVEY_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey -XIAOJU_SURVEY_ENCRYPT_TYPE_LEN=10 - -# jwt -XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret -XIAOJU_SURVEY_JWT_EXPIRES_IN=8h \ No newline at end of file diff --git a/server/.env.production b/server/.env.production new file mode 100644 index 00000000..e69de29b diff --git a/server/.eslintrc.js b/server/.eslintrc.js new file mode 100644 index 00000000..259de13c --- /dev/null +++ b/server/.eslintrc.js @@ -0,0 +1,25 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/server/.eslintrc.json b/server/.eslintrc.json deleted file mode 100644 index ae361159..00000000 --- a/server/.eslintrc.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint" - ], - "rules": { - "indent": [ - "error", - 2 - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ], - "space-in-parens": ["error", "never"], - "key-spacing": ["error", { "mode": "strict" }], - "comma-spacing": ["error", { "before": false, "after": true }], - "arrow-spacing": ["error", { "before": true, "after": true }], - "space-before-blocks": 2, - "object-curly-spacing": ["error", "always"] - } -} \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index bd8a245e..286e579e 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,106 +1,38 @@ + +package-lock.json + +# compiled output +/dist +/node_modules + # Logs logs *.log npm-debug.log* +pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# OS +.DS_Store -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# Tests +/coverage +/.nyc_output -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ -package-lock.json - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# project -build/ -src/apps/question/config/env/local.ts +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json \ No newline at end of file diff --git a/server/.prettierrc b/server/.prettierrc new file mode 100644 index 00000000..dcb72794 --- /dev/null +++ b/server/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..00a13b11 --- /dev/null +++ b/server/README.md @@ -0,0 +1,73 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ npm install +``` + +## Running the app + +```bash +# development +$ npm run start + +# watch mode +$ npm run start:dev + +# production mode +$ npm run start:prod +``` + +## Test + +```bash +# unit tests +$ npm run test + +# e2e tests +$ npm run test:e2e + +# test coverage +$ npm run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/server/nest-cli.json b/server/nest-cli.json new file mode 100644 index 00000000..f9aa683b --- /dev/null +++ b/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/server/package.json b/server/package.json index d0b5c031..c024b0ad 100644 --- a/server/package.json +++ b/server/package.json @@ -1,52 +1,91 @@ { - "name": "survey-template", - "version": "1.0.0", - "description": "survey server template", - "main": "index.js", + "name": "server-new", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", "scripts": { - "copy": "mkdir -p ./build/ && cp -rf ./src/* ./build/", - "build": "tsc", - "start:stable": "SERVER_ENV=stable node ./build/index.js", - "start:preonline": "SERVER_ENV=preonline node ./build/index.js", - "start:online": "SERVER_ENV=online node ./build/index.js", - "start": "npm run start:online", - "local": "npx ts-node scripts/run-local.ts", - "dev": "npx ts-node-dev ./src/index.ts" - }, - "devDependencies": { - "@types/crypto-js": "^4.2.1", - "@types/koa": "^2.13.8", - "@types/koa-bodyparser": "^4.3.10", - "@types/koa-router": "^7.4.4", - "@types/koa-static": "^4.0.4", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", - "cross-env": "^7.0.3", - "eslint": "^8.56.0", - "mongodb-memory-server": "^9.0.1", - "nodemon": "^2.0.20", - "ts-node": "^10.9.2", - "ts-node-dev": "^2.0.0", - "typescript": "^4.8.4" + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "local": "ts-node ./scripts/run-local.ts", + "start": "nest start", + "start:dev": "NODE_ENV=development nest start --watch", + "start:debug": "NODE_ENV=development nest start --debug --watch", + "start:prod": "NODE_ENV=production node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "NODE_ENV=development jest", + "test:watch": "NODE_ENV=development jest --watch", + "test:cov": "NODE_ENV=development jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" }, "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/serve-static": "^4.0.0", + "@nestjs/typeorm": "^10.0.1", "cheerio": "^1.0.0-rc.12", "crypto-js": "^4.2.0", - "glob": "^10.3.10", - "joi": "^17.9.2", - "jsonwebtoken": "^9.0.1", - "koa": "^2.14.2", - "koa-bodyparser": "^4.4.1", - "koa-pino-logger": "^4.0.0", - "koa-router": "^12.0.0", - "koa-static": "^4.0.3", + "dotenv": "^16.3.2", + "joi": "^17.11.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "moment": "^2.29.4", - "mongodb": "^5.7.0", - "svg-captcha": "^1.4.0" + "log4js": "^6.9.1", + "moment": "^2.30.1", + "mongodb": "^5.9.2", + "nanoid": "^3.3.7", + "node-forge": "^1.3.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "svg-captcha": "^1.4.0", + "typeorm": "^0.3.19" }, - "engines": { - "node": ">=14.21.0", - "npm": ">=6.14.17" + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/node-forge": "^1.3.11", + "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "cross-env": "^7.0.3", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "mongodb-memory-server": "^9.1.4", + "prettier": "^3.0.0", + "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", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } } } diff --git a/server/src/apps/ui/public/commom.css b/server/public/commom.css similarity index 100% rename from server/src/apps/ui/public/commom.css rename to server/public/commom.css diff --git a/server/src/apps/ui/public/favicon.ico b/server/public/favicon.ico similarity index 100% rename from server/src/apps/ui/public/favicon.ico rename to server/public/favicon.ico diff --git a/server/src/apps/ui/public/management.html b/server/public/index.html similarity index 100% rename from server/src/apps/ui/public/management.html rename to server/public/index.html diff --git a/server/public/management.html b/server/public/management.html new file mode 100644 index 00000000..3378ab44 --- /dev/null +++ b/server/public/management.html @@ -0,0 +1,16 @@ + + + + + + + 问卷管理端 + + + +
+ +

暂无数据

+
+ + \ No newline at end of file diff --git a/server/src/apps/ui/public/nodata.png b/server/public/nodata.png similarity index 100% rename from server/src/apps/ui/public/nodata.png rename to server/public/nodata.png diff --git a/server/src/apps/ui/public/render.html b/server/public/render.html similarity index 100% rename from server/src/apps/ui/public/render.html rename to server/public/render.html diff --git a/server/scripts/run-local.ts b/server/scripts/run-local.ts index 3f715176..963d3bea 100644 --- a/server/scripts/run-local.ts +++ b/server/scripts/run-local.ts @@ -9,10 +9,20 @@ async function startServerAndRunScript() { console.log('MongoDB Memory Server started:', mongoUri); // 通过 spawn 运行另一个脚本,并传递 MongoDB 连接 URL 作为环境变量 - const tsnode = spawn('cross-env', [`XIAOJU_SURVEY_MONGO_URL="${mongoUri}"`, 'npx', 'ts-node-dev', './src/index.ts'], { - stdio: 'inherit', - shell: process.platform === 'win32' - }); + const tsnode = spawn( + 'cross-env', + [ + `XIAOJU_SURVEY_MONGO_URL=${mongoUri}`, + 'NODE_ENV=development', + 'npm', + 'run', + 'start:dev', + ], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); tsnode.stdout?.on('data', (data) => { console.log(data.toString()); }); @@ -29,4 +39,4 @@ async function startServerAndRunScript() { startServerAndRunScript().catch((err) => { console.error('Error starting server and script:', err); -}); \ No newline at end of file +}); diff --git a/server/src/app.controller.ts b/server/src/app.controller.ts new file mode 100644 index 00000000..24fa1936 --- /dev/null +++ b/server/src/app.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller() +export class AppController {} diff --git a/server/src/app.module.ts b/server/src/app.module.ts new file mode 100644 index 00000000..737ffb22 --- /dev/null +++ b/server/src/app.module.ts @@ -0,0 +1,115 @@ +import { MiddlewareConsumer, Module } from '@nestjs/common'; + +import { AppController } from './app.controller'; + +import { ResponseSecurityPlugin } from './plugins/responseSecurityPlugin'; +import { SurveyUtilPlugin } from './plugins/surveyUtilPlugin'; + +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { SurveyModule } from './modules/survey/survey.module'; +import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.module'; +import { AuthModule } from './modules/auth/auth.module'; + +import { join } from 'path'; + +import { APP_FILTER } from '@nestjs/core'; +import { HttpExceptionsFilter } from './exceptions/httpExceptions.filter'; + +import { Captcha } from './models/captcha.entity'; +import { User } from './models/user.entity'; +import { SurveyMeta } from './models/surveyMeta.entity'; +import { SurveyConf } from './models/surveyConf.entity'; +import { SurveyHistory } from './models/surveyHistory.entity'; +import { ResponseSchema } from './models/responseSchema.entity'; +import { Counter } from './models/counter.entity'; +import { SurveyResponse } from './models/surveyResponse.entity'; +import { ClientEncrypt } from './models/clientEncrypt.entity'; +import { Word } from './models/word.entity'; + +import { LoggerProvider } from './logger/logger.provider'; +import { PluginManagerProvider } from './plugins/pluginManager.provider'; +import { LogRequestMiddleware } from './middlewares/logRequest.middleware'; +import { XiaojuSurveyPluginManager } from './plugins/pluginManager'; +import { Logger } from './logger'; + +@Module({ + imports: [ + ConfigModule.forRoot({}), + + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const url = await configService.get('XIAOJU_SURVEY_MONGO_URL'); + const authSource = + (await configService.get( + 'XIAOJU_SURVEY_MONGO_AUTH_SOURCE', + )) || ''; + const database = await configService.get( + 'XIAOJU_SURVEY_MONGO_DB_NAME', + ); + return { + type: 'mongodb', + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + url, + authSource, + useNewUrlParser: true, + database, + entities: [ + Captcha, + User, + SurveyMeta, + SurveyConf, + SurveyHistory, + SurveyResponse, + Counter, + ResponseSchema, + ClientEncrypt, + Word, + ], + }; + }, + }), + AuthModule, + SurveyModule, + SurveyResponseModule, + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'public'), + }), + ], + controllers: [AppController], + providers: [ + { + provide: APP_FILTER, + useClass: HttpExceptionsFilter, + }, + LoggerProvider, + PluginManagerProvider, + ], +}) +export class AppModule { + constructor( + private readonly configService: ConfigService, + private readonly pluginManager: XiaojuSurveyPluginManager, + private readonly logger: Logger, + ) {} + configure(consumer: MiddlewareConsumer) { + consumer.apply(LogRequestMiddleware).forRoutes('*'); + } + onModuleInit() { + this.pluginManager.registerPlugin( + new ResponseSecurityPlugin( + this.configService.get( + 'XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY', + ), + ), + new SurveyUtilPlugin(), + ); + this.logger.init({ + filename: this.configService.get('XIAOJU_SURVEY_LOGGER_FILENAME'), + }); + } +} diff --git a/server/src/apps/security/config/index.ts b/server/src/apps/security/config/index.ts deleted file mode 100644 index c06fa407..00000000 --- a/server/src/apps/security/config/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mongo } from '../../../config'; - -const aesEncrypt = { - key: process.env.XIAOJU_SURVEY_ENCRYPT_SECRET_KEY || 'dataAesEncryptSecretKey', -}; - -export function getConfig() { - return { - mongo, - aesEncrypt, - }; -} \ No newline at end of file diff --git a/server/src/apps/security/dataSecurityPlugins/aesPlugin/index.ts b/server/src/apps/security/dataSecurityPlugins/aesPlugin/index.ts deleted file mode 100644 index 1dcc06ef..00000000 --- a/server/src/apps/security/dataSecurityPlugins/aesPlugin/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DataSecurityPlugin } from '../interface'; -import * as CryptoJS from 'crypto-js'; -import { isAddress, isEmail, isGender, isIdCard, isPhone } from './util'; - - -export default class AesDataSecurityPlugin implements DataSecurityPlugin { - secretKey: string; - constructor({ secretKey }) { - this.secretKey = secretKey; - } - isDataSensitive(data: string): boolean { - const testArr = [isPhone, isIdCard, isAddress, isEmail, isGender]; - for (const test of testArr) { - if (test(data)) { - return true; - } - } - return false; - } - encryptData(data: string): string { - return CryptoJS.AES.encrypt(data, this.secretKey).toString(); - } - decryptData(data: string): string { - return CryptoJS.AES.decrypt(data, this.secretKey).toString(CryptoJS.enc.Utf8); - } - desensitiveData(data: string): string { - if (data.length === 1) { - return '*'; - } - if (data.length === 2) { - return data[0] + '*'; - } - return data[0] + '***' + data[data.length - 1]; - } -} \ No newline at end of file diff --git a/server/src/apps/security/dataSecurityPlugins/aesPlugin/util.ts b/server/src/apps/security/dataSecurityPlugins/aesPlugin/util.ts deleted file mode 100644 index d2065488..00000000 --- a/server/src/apps/security/dataSecurityPlugins/aesPlugin/util.ts +++ /dev/null @@ -1,19 +0,0 @@ -const phoneRegex = /^1[3456789]\d{9}$/; // 手机号码正则表达式 -const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/; // 身份证号码正则表达式 -const addressRegex = /.*省|.*自治区|.*市|.*区|.*镇|.*县/; // 地址正则表达式 -const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // 邮箱正则 -const genderArr = ['男', '女']; // 性别 -const nameRegex = /^([\u4e00-\u9fa5]{1,6}|[a-zA-Z.\s]{1,20})$/; // 只能识别是否包含中文,无法识别是否是姓名,暂时不启用 - - -export const isPhone = data => phoneRegex.test(data); - -export const isIdCard = data => idCardRegex.test(data); - -export const isAddress = data => addressRegex.test(data); - -export const isEmail = data => emailRegex.test(data); - -export const isGender = data => genderArr.includes(data); - -export const isName = data => nameRegex.test(data); \ No newline at end of file diff --git a/server/src/apps/security/dataSecurityPlugins/interface.ts b/server/src/apps/security/dataSecurityPlugins/interface.ts deleted file mode 100644 index cb8a8696..00000000 --- a/server/src/apps/security/dataSecurityPlugins/interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface DataSecurityPlugin { - isDataSensitive(data: string): boolean; - encryptData(data: string): string; - decryptData(data: string): string; - desensitiveData(data: string): string; -} \ No newline at end of file diff --git a/server/src/apps/security/db/mongo.ts b/server/src/apps/security/db/mongo.ts deleted file mode 100644 index cc3ec119..00000000 --- a/server/src/apps/security/db/mongo.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { getConfig } from '../config/index'; - -import MongoService from '../../../utils/mongoService'; - -const config = getConfig(); - -export const mongo = new MongoService({ url: config.mongo.url, dbName: config.mongo.dbName }); \ No newline at end of file diff --git a/server/src/apps/security/index.ts b/server/src/apps/security/index.ts deleted file mode 100644 index 51044a3a..00000000 --- a/server/src/apps/security/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { SurveyApp, SurveyServer } from '../../decorator'; -import { securityService } from './service/securityService'; -import { isString } from './utils'; -import AesDataSecurityPlugin from './dataSecurityPlugins/aesPlugin/index'; -import { load } from 'cheerio'; -import { getConfig } from './config/index'; - -const config = getConfig(); -const pluginInstance = new AesDataSecurityPlugin({ secretKey: config.aesEncrypt.key }); - -@SurveyApp('/api/security') -export default class Security { - @SurveyServer({ type: 'rpc' }) - async isHitKeys({ params, context }) { - const data = securityService.isHitKeys({ - content: params.content, - dictType: params.dictType, - }); - return { - result: data, - context, // 上下文主要是传递调用方信息使用,比如traceid - }; - } - - @SurveyServer({ type: 'rpc' }) - isDataSensitive(data) { - if (!isString(data)) { - return false; - } - const $ = load(data); - const text = $.text(); - return pluginInstance.isDataSensitive(text); - } - - @SurveyServer({ type: 'rpc' }) - encryptData(data) { - if (!isString(data)) { - return data; - } - return pluginInstance.encryptData(data); - } - - @SurveyServer({ type: 'rpc' }) - decryptData(data) { - if (!isString(data)) { - return data; - } - return pluginInstance.decryptData(data); - } - - @SurveyServer({ type: 'rpc' }) - desensitiveData(data) { - // 数据脱敏 - if (!isString(data)) { - return '*'; - } - const $ = load(data); - const text = $.text(); - return pluginInstance.desensitiveData(text); - } -} \ No newline at end of file diff --git a/server/src/apps/security/service/securityService.ts b/server/src/apps/security/service/securityService.ts deleted file mode 100644 index cfe0fb4a..00000000 --- a/server/src/apps/security/service/securityService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { mongo } from '../db/mongo'; -import { DICT_TYPE } from '../../../types'; -import { participle } from '../utils/index'; - -class SecurityService { - async isHitKeys({ content, dictType }: { content: string, dictType: DICT_TYPE }) { - const securityDictModel = await mongo.getCollection({ collectionName: 'securityDict' }); - const keywordList = participle({ content }); - const hitCount = await securityDictModel.countDocuments({ keyword: { $in: keywordList }, type: dictType }); - return hitCount > 0; - } -} - -export const securityService = new SecurityService(); \ No newline at end of file diff --git a/server/src/apps/security/utils/index.ts b/server/src/apps/security/utils/index.ts deleted file mode 100644 index cc836eae..00000000 --- a/server/src/apps/security/utils/index.ts +++ /dev/null @@ -1,18 +0,0 @@ - -export function participle({ content, minLen, maxLen }: { content: string, minLen?: number, maxLen?: number }) { - const keys: Array = []; - minLen = minLen || 2; - maxLen = maxLen || 13; - for (let i = 0; i < content.length; i++) { - let tempStr = content[i]; - for (let j = 1; j < maxLen && i + j < content.length; j++) { - tempStr += content[i + j]; - if (j >= minLen - 1) { - keys.push(tempStr); - } - } - } - return keys; -} - -export const isString = data => typeof data === 'string'; diff --git a/server/src/apps/surveyManage/config/index.ts b/server/src/apps/surveyManage/config/index.ts deleted file mode 100644 index 39c38c8d..00000000 --- a/server/src/apps/surveyManage/config/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { mongo } from '../../../config'; - -export function getConfig() { - return { - mongo, - }; -} \ No newline at end of file diff --git a/server/src/apps/surveyManage/db/mongo.ts b/server/src/apps/surveyManage/db/mongo.ts deleted file mode 100644 index e368f475..00000000 --- a/server/src/apps/surveyManage/db/mongo.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getConfig } from '../config/index'; -import MongoService from '../../../utils/mongoService'; - -const config = getConfig(); - -export const mongo = new MongoService({ url: config.mongo.url, dbName: config.mongo.dbName }); \ No newline at end of file diff --git a/server/src/apps/surveyManage/index.ts b/server/src/apps/surveyManage/index.ts deleted file mode 100644 index eb318a9a..00000000 --- a/server/src/apps/surveyManage/index.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { SurveyApp, SurveyServer } from '../../decorator'; -import { surveyService } from './service/surveyService'; -import { userService } from './service/userService'; -import { surveyHistoryService } from './service/surveyHistoryService'; -import { HISTORY_TYPE } from '../../types/index'; -import { getValidateValue } from './utils/index'; -import * as Joi from 'joi'; -import { CommonError } from '../../types/index'; - -type FilterItem = { - comparator?: string; - condition: Array; -} - -type FilterCondition = { - field: string; - comparator?: string; - value: string & Array; -} - -@SurveyApp('/api/surveyManage') -export default class SurveyManage { - - @SurveyServer({ type: 'http', method: 'get', routerName: '/getBannerData' }) - async getBannerData() { - const data = await surveyService.getBannerData(); - return { - code: 200, - data, - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/add' }) - async add({ req }) { - const params = getValidateValue(Joi.object({ - remark: Joi.string().required(), - questionType: Joi.string().required(), - title: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - params.userData = await userService.checkLogin({ req }); - const addRes = await surveyService.add(params); - return { - code: 200, - data: { - id: addRes.pageId, - }, - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/create' }) - async create({ req }) { - const params = getValidateValue(Joi.object({ - remark: Joi.string().required(), - title: Joi.string().required(), - questionType: Joi.string().when('createMethod', { - is: 'copy', - then: Joi.allow(null), - otherwise: Joi.required(), - }), - createMethod: Joi.string().allow(null).default('basic'), - createFrom: Joi.string().when('createMethod', { - is: 'copy', - then: Joi.required(), - otherwise: Joi.allow(null), - }), - }).validate(req.body, { allowUnknown: true })); - params.userData = await userService.checkLogin({ req }); - const addRes = await surveyService.create(params); - return { - code: 200, - data: { - id: addRes.pageId, - }, - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/update' }) - async update({ req }) { - const surveyParams = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - remark: Joi.string().required(), - title: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - const userData = await userService.checkLogin({ req }); - surveyParams.userData = userData; - const data = await surveyService.update(surveyParams); - return { - code: 200, - data, - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/delete' }) - async delete({ req }) { - const surveyParams = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - const userData = await userService.checkLogin({ req }); - surveyParams.userData = userData; - const data = await surveyService.delete(surveyParams); - return { - code: 200, - data, - }; - } - @SurveyServer({ type: 'http', method: 'get', routerName: '/list' }) - async list({ req }) { - const condition = getValidateValue(Joi.object({ - curPage: Joi.number().default(1), - pageSize: Joi.number().default(10), - filter: Joi.string().allow(null), - order: Joi.string().allow(null), - }).validate(req.query, { allowUnknown: true })); - let filter = {}, order = {}; - if (condition.filter) { - try { - filter = this.getFilter(JSON.parse(decodeURIComponent(condition.filter))); - } catch (error) { - throw new CommonError('filter参数格式不正确'); - } - } - if (condition.order) { - try { - order = this.getOrder(JSON.parse(decodeURIComponent(condition.order))); - } catch (error) { - throw new CommonError('order参数格式不正确'); - } - } - const userData = await userService.checkLogin({ req }); - const listRes = await surveyService.list({ - pageNum: condition.curPage, - pageSize: condition.pageSize, - filter, - order, - userData - }); - return { - code: 200, - data: listRes, - }; - } - - private getFilter(filterList: Array) { - const allowFilterField = ['title', 'remark', 'questionType', 'curStatus.status']; - return filterList.reduce((preItem, curItem) => { - const condition = curItem.condition.filter(item => allowFilterField.includes(item.field)).reduce((pre, cur) => { - switch(cur.comparator) { - case '$ne': - pre[cur.field] = { - $ne: cur.value, - }; - break; - case '$regex': - pre[cur.field] = { - $regex: cur.value, - }; - break; - default: - pre[cur.field] = cur.value; - break; - } - return pre; - }, {}); - switch(curItem.comparator) { - case '$or': - if (!Array.isArray(preItem.$or)) { - preItem.$or = []; - } - preItem.$or.push(condition); - break; - default: - Object.assign(preItem, condition); - break; - } - return preItem; - }, { } as { $or?: Array>; } & Record); - } - - private getOrder(order) { - - const allowOrderFields = ['createDate', 'updateDate', 'curStatus.date']; - - const orderList = order.filter((orderItem) => - allowOrderFields.includes(orderItem.field), - ); - return orderList.reduce((pre, cur) => { - pre[cur.field] = cur.value === 1 ? 1 : -1; - return pre; - }, {}); - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/saveConf' }) - async saveConf({ req }) { - const surveyData = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - configData: Joi.object().required(), - }).validate(req.body, { allowUnknown: true })); - const userData = await userService.checkLogin({ req }); - // 保存数据 - const saveRes = await surveyService.saveConf(surveyData); - // 保存历史 - const historyRes = await surveyHistoryService.addHistory({ - surveyId: surveyData.surveyId, - configData: surveyData.configData, - type: HISTORY_TYPE.dailyHis, - userData - }); - return { - code: 200, - data: { - saveRes, - historyRes - } - }; - } - - @SurveyServer({ type: 'http', method: 'get', routerName: '/get' }) - async get({ req }) { - const params = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - }).validate(req.query, { allowUnknown: true })); - const userData = await userService.checkLogin({ req }); - const data = await surveyService.get({ - surveyId: params.surveyId, - userData - }); - return { - code: 200, - data, - }; - } - - @SurveyServer({ type: 'http', method: 'get', routerName: '/getHistoryList' }) - async getHistoryList({ req }) { - const historyParams = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - historyType: Joi.string().required(), - }).validate(req.query, { allowUnknown: true })); - const data = await surveyHistoryService.getHistoryList(historyParams); - return { - code: 200, - data - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/publish' }) - async publish({ req }) { - const surveyParams = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - // 鉴权 - const userData = await userService.checkLogin({ req }); - // 发布 - surveyParams.userData = userData; - const surveyData = await surveyService.publish(surveyParams); - // 保存历史 - const historyRes = await surveyHistoryService.addHistory({ - surveyId: surveyData.surveyConfRes.pageId, - configData: surveyData.surveyConfRes.code, - type: HISTORY_TYPE.publishHis, - userData - }); - return { - code: 200, - data: { - ...surveyData, - historyRes - } - }; - } - - @SurveyServer({ type: 'http', method: 'get', routerName: '/data' }) - async data({ req }) { - const surveyParams = getValidateValue(Joi.object({ - surveyId: Joi.string().required(), - isShowSecret: Joi.boolean().default(true), // 默认true就是需要脱敏 - page: Joi.number().default(1), - pageSize: Joi.number().default(10), - }).validate(req.query, { allowUnknown: true })); - const userData = await userService.checkLogin({ req }); - const data = await surveyService.data({ - userData, - surveyId: surveyParams.surveyId, - isShowSecret: surveyParams.isShowSecret, - pageNum: surveyParams.page, - pageSize: surveyParams.pageSize, - }); - return { - code: 200, - data - }; - } -} \ No newline at end of file diff --git a/server/src/apps/surveyManage/service/surveyHistoryService.ts b/server/src/apps/surveyManage/service/surveyHistoryService.ts deleted file mode 100644 index 0bd8122c..00000000 --- a/server/src/apps/surveyManage/service/surveyHistoryService.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { mongo } from '../db/mongo'; -import { getStatusObject } from '../utils/index'; -import { SURVEY_STATUS, HISTORY_TYPE, UserType } from '../../../types/index'; - -class SurveyHistoryService { - async addHistory(surveyData: { surveyId: string, configData: unknown, type: HISTORY_TYPE, userData: UserType }) { - const surveyHistory = await mongo.getCollection({ collectionName: 'surveyHistory' }); - const surveyHistoryRes = await surveyHistory.insertOne({ - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - pageId: surveyData.surveyId, - type: surveyData.type, - code: { - data: surveyData.configData - }, - createDate: Date.now(), - operator: { - _id: surveyData.userData._id, - username: surveyData.userData.username, - } - }); - return surveyHistoryRes; - } - - async getHistoryList(historyParams: { surveyId: string, historyType: HISTORY_TYPE }) { - const surveyHistory = await mongo.getCollection({ collectionName: 'surveyHistory' }); - const surveyHistoryListRes = await surveyHistory.find({ - pageId: historyParams.surveyId, - type: historyParams.historyType, - }) - .sort({ createDate: -1 }) - .limit(100) - .toArray(); - return mongo.convertId2StringByList(surveyHistoryListRes); - } -} - -export const surveyHistoryService = new SurveyHistoryService(); \ No newline at end of file diff --git a/server/src/apps/surveyManage/service/surveyService.ts b/server/src/apps/surveyManage/service/surveyService.ts deleted file mode 100644 index 99090d97..00000000 --- a/server/src/apps/surveyManage/service/surveyService.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { mongo } from '../db/mongo'; -import { rpcInvote } from '../../../rpc'; -import { SURVEY_STATUS, QUESTION_TYPE, CommonError, UserType, DICT_TYPE } from '../../../types/index'; -import { getStatusObject, genSurveyPath } from '../utils/index'; -import * as path from 'path'; -import { keyBy, merge, cloneDeep } from 'lodash'; -import * as moment from 'moment'; -import { DataItem } from '../../../types/survey'; -import { Sort } from 'mongodb'; - -class SurveyService { - async checkSecurity({ content, dictType }: { content: string, dictType: DICT_TYPE }) { - const rpcResult = await rpcInvote('security.isHitKeys', { - params: { content, dictType }, - context: {} - }); - return rpcResult.result; - } - - async getBannerData() { - const bannerConfPath = path.resolve(__dirname, '../template/banner/index.json'); - return await import(bannerConfPath); - } - - async getCodeData({ - questionType, - }: { questionType: QUESTION_TYPE }): Promise { - const baseConfPath = path.resolve(__dirname, '../template/surveyTemplate/templateBase.json'); - const templateConfPath = path.resolve( - __dirname, - `../template/surveyTemplate/survey/${questionType}.json`, - ); - - const baseConf = cloneDeep(await import(baseConfPath)); - const templateConf = cloneDeep(await import(templateConfPath)); - const codeData = merge(baseConf, templateConf); - const nowMoment = moment(); - codeData.baseConf.begTime = nowMoment.format('YYYY-MM-DD HH:mm:ss'); - codeData.baseConf.endTime = nowMoment.add(10, 'years').format('YYYY-MM-DD HH:mm:ss'); - return codeData; - } - async getNewSurveyPath() { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyPath = genSurveyPath(); - const surveyPathCount = await surveyMeta.countDocuments({ surveyPath }); - if (surveyPathCount > 0) { return await this.getNewSurveyPath(); } - return surveyPath; - } - - async add(surveyMetaInfo: { remark: string, questionType: QUESTION_TYPE, title: string, userData: UserType }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const now = Date.now(); - const surveyPath = await this.getNewSurveyPath(); - const surveyMetaRes = await surveyMeta.insertOne({ - surveyPath, - remark: surveyMetaInfo.remark, - questionType: surveyMetaInfo.questionType, - title: surveyMetaInfo.title, - creator: surveyMetaInfo.userData.username, - owner: surveyMetaInfo.userData.username, - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - createDate: now, - updateDate: now, - }); - const pageId = surveyMetaRes.insertedId.toString(); - const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' }); - const surveyConfRes = await surveyConf.insertOne({ - pageId, - pageType: surveyMetaInfo.questionType, - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - code: await this.getCodeData({ - questionType: surveyMetaInfo.questionType, - }) - }); - return { - pageId, - surveyMetaRes, - surveyConfRes - }; - } - - async create(surveyMetaInfo: { remark: string, questionType: QUESTION_TYPE, title: string, userData: UserType, createMethod: string; createFrom: string; }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const now = Date.now(); - const surveyPath = await this.getNewSurveyPath(); - - let originSurvey; - if (surveyMetaInfo.createMethod === 'copy') { - originSurvey = await this.get({ surveyId: surveyMetaInfo.createFrom, userData: surveyMetaInfo.userData }); - surveyMetaInfo.questionType = originSurvey.surveyMetaRes.questionType; - } - - const surveyMetaRes = await surveyMeta.insertOne({ - surveyPath, - remark: surveyMetaInfo.remark, - questionType: surveyMetaInfo.questionType, - createMethod: surveyMetaInfo.createMethod || 'basic', - createFrom: surveyMetaInfo.createFrom || '', - title: surveyMetaInfo.title, - creator: surveyMetaInfo.userData.username, - owner: surveyMetaInfo.userData.username, - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - createDate: now, - updateDate: now, - }); - const pageId = surveyMetaRes.insertedId.toString(); - const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' }); - const code = originSurvey ? originSurvey.surveyConfRes.code : await this.getCodeData({ - questionType: surveyMetaInfo.questionType, - }); - const surveyConfRes = await surveyConf.insertOne({ - pageId, - pageType: surveyMetaInfo.questionType, - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - code, - }); - return { - pageId, - surveyMetaRes, - surveyConfRes - }; - } - - async update(surveyParams: { surveyId: string, remark: string, title: string, userData: UserType }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const _id = mongo.getObjectIdByStr(surveyParams.surveyId); - const surveyMetaUpdateRes = await surveyMeta.updateOne({ - _id, - owner: surveyParams.userData.username, - }, [{ - $set: { - remark: surveyParams.remark, - title: surveyParams.title, - updateDate: Date.now(), - } - }, { - $set: { - 'curStatus': { - $cond: { - if: { - $eq: ['$curStatus.status', 'new'] - }, - then: '$curStatus', - else: getStatusObject({ status: SURVEY_STATUS.editing }) - } - } - } - }]); - if (surveyMetaUpdateRes.matchedCount < 1) { - throw new CommonError('更新问卷信息失败,问卷不存在或您不是该问卷所有者'); - } - return { - surveyMetaUpdateRes - }; - } - - async delete(surveyParams: { surveyId: string, userData: UserType }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const _id = mongo.getObjectIdByStr(surveyParams.surveyId); - const surveyMetaDeleteRes = await surveyMeta.deleteOne({ - _id, - owner: surveyParams.userData.username, - }); - if (surveyMetaDeleteRes.deletedCount < 1) { - throw new CommonError('删除问卷失败,问卷已被删除或您不是该问卷所有者'); - } - return { - surveyMetaDeleteRes - }; - } - - async list(condition: { pageNum: number, pageSize: number, userData: UserType, filter: object, order: object }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - - const query = Object.assign( - {}, - { - owner: condition.userData.username, - 'curStatus.status': { - $ne: 'removed', - }, - }, - condition.filter, - ); - const order = condition.order && Object.keys(condition.order).length > 0 ? condition.order as Sort : { - createDate: -1, - } as Sort; - const data = await surveyMeta.find(query) - .sort(order) - .limit(condition.pageSize) - .skip((condition.pageNum - 1) * condition.pageSize) - .toArray(); - const count = await surveyMeta.countDocuments(query); - return { data: mongo.convertId2StringByList(data), count }; - } - - getListHeadByDataList(dataList) { - const listHead = dataList.map(surveyItem => { - let othersCode; - if (surveyItem.type === 'radio-star') { - const rangeConfigKeys = Object.keys(surveyItem.rangeConfig); - if (rangeConfigKeys.length > 0) { - othersCode = [{ code: `${surveyItem.field}_custom`, option: '填写理由' }]; - } - } else { - othersCode = (surveyItem.options || []) - .filter(optionItem => optionItem.othersKey) - .map((optionItem) => { - return { - code: optionItem.othersKey, - option: optionItem.text - }; - }); - } - return { - field: surveyItem.field, - title: surveyItem.title, - type: surveyItem.type, - othersCode - }; - }); - listHead.push({ - field: 'difTime', - title: '答题耗时(秒)', - type: 'text', - }); - listHead.push({ - field: 'createDate', - title: '提交时间', - type: 'text', - }); - return listHead; - } - - async data(condition: { userData: UserType, surveyId: string, pageNum: number, pageSize: number, isShowSecret: boolean }) { - const surveyObjectId = mongo.getObjectIdByStr(condition.surveyId); - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyMetaData = await surveyMeta.findOne({ _id: surveyObjectId }); - if (surveyMetaData.owner !== condition.userData.username) { - throw new CommonError('问卷回收数据列表仅所有人才能打开'); - } - const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' }); - const publishConf = await surveyPublish.findOne({ pageId: condition.surveyId }); - const dataList = publishConf?.code?.dataConf?.dataList || []; - const listHead = this.getListHeadByDataList(dataList); - const dataListMap = keyBy(dataList, 'field'); - const surveySubmit = await mongo.getCollection({ collectionName: 'surveySubmit' }); - const surveySubmitDataList = await surveySubmit.find({ pageId: condition.surveyId }) - .sort({ createDate: -1 }) - .limit(condition.pageSize) - .skip((condition.pageNum - 1) * condition.pageSize) - .toArray(); - - const listBody = surveySubmitDataList.map(submitedData => { - const data = submitedData.data; - const secretKeys = submitedData.secretKeys || []; - const dataKeys = Object.keys(data); - - for (const itemKey of dataKeys) { - if (typeof itemKey !== 'string') { continue; } - if (itemKey.indexOf('data') !== 0) { continue; } - // 获取题目id - const itemConfigKey = itemKey.split('_')[0]; - // 获取题目 - const itemConfig: DataItem = dataListMap[itemConfigKey]; - // 题目删除会出现,数据列表报错 - if (!itemConfig) { continue; } - // 处理选项的更多输入框 - if (itemConfig.type === 'radio-star' && !data[`${itemConfigKey}_custom`]) { - data[`${itemConfigKey}_custom`] = data[`${itemConfigKey}_${data[itemConfigKey]}`]; - } - // 解密数据 - if (secretKeys.includes(itemKey)) { - data[itemKey] = Array.isArray(data[itemKey]) ? data[itemKey].map(item => rpcInvote('security.decryptData', item)) : rpcInvote('security.decryptData', data[itemKey]); - } - // 将选项id还原成选项文案 - if (Array.isArray(itemConfig.options) && itemConfig.options.length > 0) { - const optionTextMap = keyBy(itemConfig.options, 'hash'); - data[itemKey] = Array.isArray(data[itemKey]) ? data[itemKey].map(item => optionTextMap[item]?.text || item).join(',') : optionTextMap[data[itemKey]]?.text || data[itemKey]; - } - // 数据脱敏 - if (condition.isShowSecret && rpcInvote('security.isDataSensitive', data[itemKey])) { - data[itemKey] = rpcInvote('security.desensitiveData', data[itemKey]); - } - } - return { - ...data, - difTime: (submitedData.difTime / 1000).toFixed(2), - createDate: moment(submitedData.createDate).format('YYYY-MM-DD HH:mm:ss') - }; - }); - const total = await surveySubmit.countDocuments({ pageId: condition.surveyId }); - return { - total, - listHead, - listBody - }; - } - - - async get({ surveyId, userData }: { surveyId: string, userData: UserType }) { - const surveyObjectId = mongo.getObjectIdByStr(surveyId); - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyMetaData = await surveyMeta.findOne({ _id: surveyObjectId }); - if (!surveyMetaData) { - throw new CommonError('问卷不存在或已被删除'); - } - if (surveyMetaData.owner !== userData.username) { - throw new CommonError('问卷仅所有人才能打开'); - } - const surveyMetaRes = mongo.convertId2StringByDoc(surveyMetaData); - const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' }); - const surveyConfData = await surveyConf.findOne({ pageId: surveyId }); - if (!surveyConfData) { - throw new CommonError('问卷配置不存在或已被删除'); - } - const surveyConfRes = mongo.convertId2StringByDoc(surveyConfData); - return { - surveyMetaRes, - surveyConfRes - }; - } - - - async saveConf(surveyData: { surveyId: string, configData: unknown }) { - const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' }); - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const saveRes = await surveyConf.updateOne({ - pageId: surveyData.surveyId - }, { - $set: { - code: surveyData.configData, - } - }); - const _id = mongo.getObjectIdByStr(surveyData.surveyId); - surveyMeta.updateOne({ - _id, - }, [{ - $set: { - 'curStatus': { - $cond: { - if: { - $eq: ['$curStatus.status', 'new'] - }, - then: '$curStatus', - else: getStatusObject({ status: SURVEY_STATUS.editing }) - } - } - } - }]); - return saveRes; - } - - async publish({ surveyId, userData }: { surveyId: string, userData: UserType }) { - const surveyObjectId = mongo.getObjectIdByStr(surveyId); - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' }); - const surveyMetaRes = await surveyMeta.findOne({ _id: surveyObjectId }); - if (!surveyMetaRes) { - throw new CommonError('问卷不存在或已被删除,无法发布'); - } - if (surveyMetaRes.owner !== userData.username) { - throw new CommonError('只有问卷的所有者才能发布该问卷'); - } - const surveyConfRes = await surveyConf.findOne({ pageId: surveyId }); - if (!surveyConfRes) { - throw new CommonError('问卷配置不存在或已被删除,无法发布'); - } - const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' }); - // 清除id存储发布 - delete surveyConfRes._id; - surveyConfRes.title = surveyMetaRes.title; - surveyConfRes.curStatus = surveyMetaRes.curStatus; - surveyConfRes.surveyPath = surveyMetaRes.surveyPath; - const dataList = surveyConfRes?.code?.dataConf?.dataList || []; - for (const data of dataList) { - const isDangerKey = await this.checkSecurity({ content: data.title, dictType: DICT_TYPE.danger }); - if (isDangerKey) { - throw new CommonError('问卷存在非法关键字,不允许发布'); - } - const isSecretKey = await this.checkSecurity({ content: data.title, dictType: DICT_TYPE.secret }); - if (isSecretKey) { - data.isSecret = true; - } - } - const publishRes = await surveyPublish.updateOne({ - pageId: surveyId - }, { - $set: surveyConfRes - }, { - upsert: true //如果不存在则插入 - }); - const updateMetaRes = await surveyMeta.updateOne({ - _id: surveyObjectId - }, { - $set: { - curStatus: getStatusObject({ status: SURVEY_STATUS.published }), - } - }); - return { - updateMetaRes, - surveyConfRes, - publishRes - }; - } -} - -export const surveyService = new SurveyService(); \ No newline at end of file diff --git a/server/src/apps/surveyManage/service/userService.ts b/server/src/apps/surveyManage/service/userService.ts deleted file mode 100644 index cbe795d9..00000000 --- a/server/src/apps/surveyManage/service/userService.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { rpcInvote } from '../../../rpc'; -import { Request } from 'koa'; -import { UserType, CommonError } from '../../../types/index'; - -class UserService { - async checkLogin({ req }: { req: Request }) { - if (!req.headers['authorization']) { - throw new CommonError('请先登录', 403); - } - const token = (String(req.headers['authorization']) || '').replace('Bearer ', ''); - const rpcResult = await rpcInvote('user.getUserByToken', { - params: { token }, - context: req - }); - return rpcResult.result; - } -} - -export const userService = new UserService(); \ No newline at end of file diff --git a/server/src/apps/surveyManage/utils/base58.ts b/server/src/apps/surveyManage/utils/base58.ts deleted file mode 100644 index 78b6b6da..00000000 --- a/server/src/apps/surveyManage/utils/base58.ts +++ /dev/null @@ -1,37 +0,0 @@ -const base58KeysObject = { - '1': 0, '2': 1, '3': 2, '4': 3, '5': 4, '6': 5, '7': 6, '8': 7, '9': 8, - 'A': 9, 'B': 10, 'C': 11, 'D': 12, 'E': 13, 'F': 14, 'G': 15, 'H': 16, 'J': 17, - 'K': 18, 'L': 19, 'M': 20, 'N': 21, 'P': 22, 'Q': 23, 'R': 24, 'S': 25, 'T': 26, - 'U': 27, 'V': 28, 'W': 29, 'X': 30, 'Y': 31, 'Z': 32, - 'a': 33, 'b': 34, 'c': 35, 'd': 36, 'e': 37, 'f': 38, 'g': 39, 'h': 40, 'i': 41, 'j': 42, - 'k': 43, 'm': 44, 'n': 45, 'o': 46, 'p': 47, 'q': 48, 'r': 49, 's': 50, 't': 51, - 'u': 52, 'v': 53, 'w': 54, 'x': 55, 'y': 56, 'z': 57 -}; -const base58Keys = Object.keys(base58KeysObject); -const base58Len = 58n; - -export function hex2Base58(hexNum:string):string -{ - const base58NumArray =[]; - let bigHexNumber = BigInt(`0x${hexNum}`); - while (bigHexNumber>=58n) - { - base58NumArray.unshift(base58Keys[(bigHexNumber % base58Len).toString()]); - bigHexNumber = bigHexNumber / base58Len; - } - base58NumArray.unshift(base58Keys[bigHexNumber.toString()]); - return base58NumArray.join(''); -} - -export function base582Hex(base58Num:string):string -{ - const base58NumArray =base58Num.split(''); - let big58Number = 0n; - const len = base58NumArray.length; - for(let i = 1;i<=len;i++) - { - const big58NumberTemp = BigInt(base58KeysObject[base58NumArray[len-i]])*(base58Len** BigInt(i-1)); - big58Number += big58NumberTemp; - } - return big58Number.toString(16); -} \ No newline at end of file diff --git a/server/src/apps/surveyManage/utils/index.ts b/server/src/apps/surveyManage/utils/index.ts deleted file mode 100644 index fb4c856a..00000000 --- a/server/src/apps/surveyManage/utils/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SURVEY_STATUS, CommonError } from '../../../types/index'; -import { hex2Base58 } from './base58'; -import * as Joi from 'joi'; -import * as fs from 'fs'; - -export function getStatusObject({ status }: { status: SURVEY_STATUS }) { - return { - status, - id: status, - date: Date.now(), - }; -} - -export function getValidateValue(validationResult: Joi.ValidationResult): T { - if (validationResult.error) { - throw new CommonError(validationResult.error.details.map(e => e.message).join()); - } - return validationResult.value; -} - -export function genSurveyPath() { - return hex2Base58(process.hrtime.bigint().toString(16)); -} - -export const getFile = function(path, { encoding }: { encoding } = { encoding: 'utf-8' }): Promise { - return new Promise((resolve, reject) => { - - fs.stat(path, err => { - if (!err) { - fs.readFile(path, { encoding }, (err, data) => { - if (err) { - reject(err); - } else { - resolve(data.toString()); - } - }); - } else { - reject(err); - } - }); - }); - -}; \ No newline at end of file diff --git a/server/src/apps/surveyPublish/config/index.ts b/server/src/apps/surveyPublish/config/index.ts deleted file mode 100644 index 1ae6c0a9..00000000 --- a/server/src/apps/surveyPublish/config/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { mongo, session, encrypt } from '../../../config'; - -export function getConfig() { - return { - mongo, - session, - encrypt, - }; -} \ No newline at end of file diff --git a/server/src/apps/surveyPublish/db/mongo.ts b/server/src/apps/surveyPublish/db/mongo.ts deleted file mode 100644 index e368f475..00000000 --- a/server/src/apps/surveyPublish/db/mongo.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getConfig } from '../config/index'; -import MongoService from '../../../utils/mongoService'; - -const config = getConfig(); - -export const mongo = new MongoService({ url: config.mongo.url, dbName: config.mongo.dbName }); \ No newline at end of file diff --git a/server/src/apps/surveyPublish/index.ts b/server/src/apps/surveyPublish/index.ts deleted file mode 100644 index dcd06ac9..00000000 --- a/server/src/apps/surveyPublish/index.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { SurveyApp, SurveyServer } from '../../decorator'; -import { surveySubmitService } from './service/surveySubmitService'; -import { surveyPublishService } from './service/surveyPublishService'; -import { getValidateValue } from './utils/index'; -import { checkSign } from './utils/checkSign'; -import * as Joi from 'joi'; - -@SurveyApp('/api/surveyPublish') -export default class SurveyPublish { - // 获取发布配置 - @SurveyServer({ type: 'http', method: 'get', routerName: '/getSurveyPublish' }) - async getSurveyPublish({ req }) { - const surveySubmitData = getValidateValue(Joi.object({ - surveyPath: Joi.string().required(), - }).validate(req.query, { allowUnknown: true })); - const data = await surveyPublishService.get(surveySubmitData); - return { - code: 200, - data: data.surveyPublishRes, - }; - } - // 获取投票 - @SurveyServer({ type: 'http', method: 'get', routerName: '/queryVote' }) - async queryVote({ req }) { - const params = getValidateValue(Joi.object({ - surveyPath: Joi.string().required(), - voteKeyList: Joi.string().required(), - }).validate(req.query, { allowUnknown: true })); - params.voteKeyList = params.voteKeyList.split(','); - const data = await surveyPublishService.queryVote(params); - return { - code: 200, - data: data, - }; - } - - @SurveyServer({ type: 'http', method: 'get', routerName: '/getEncryptInfo' }) - async getEncryptInfo() { - const data = await surveySubmitService.getEncryptInfo(); - return { - code: 200, - data: data, - }; - } - // 提交问卷 - @SurveyServer({ type: 'http', method: 'post', routerName: '/submit' }) - async submit({ req }) { - // 检查签名 - checkSign(req.body); - // 校验参数 - const surveySubmitData = getValidateValue(Joi.object({ - surveyPath: Joi.string().required(), - data: Joi.string().required(), - encryptType: Joi.string(), - sessionId: Joi.string(), - }).validate(req.body, { allowUnknown: true })); - await surveySubmitService.submit({ surveySubmitData }); - return { - code: 200, - msg: '提交成功', - }; - } -} \ No newline at end of file diff --git a/server/src/apps/surveyPublish/service/surveyKeyStoreService.ts b/server/src/apps/surveyPublish/service/surveyKeyStoreService.ts deleted file mode 100644 index fe9cf63f..00000000 --- a/server/src/apps/surveyPublish/service/surveyKeyStoreService.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { mongo } from '../db/mongo'; -import { KeyStore } from '../../../types/keyStore'; -// 该服务用于模拟redis -class SurveyKeyStoreService { - getKeyStoreResult(surveyKeyStoreData: Array) { - const surveyKeyStoreReult = {}; - for (const surveyKeyStoreItem of surveyKeyStoreData) { - surveyKeyStoreReult[surveyKeyStoreItem.key] = surveyKeyStoreItem.data; - } - return surveyKeyStoreReult; - } - - async set({ surveyPath, key, data, type }) { - const surveyKeyStore = await mongo.getCollection({ collectionName: 'surveyKeyStore' }); - const setResult = await surveyKeyStore.updateOne({ - key, - surveyPath, - type - }, { - $set: { - key, - surveyPath, - type, - data, - createDate: Date.now(), - updateDate: Date.now(), - } - }, { - upsert: true //如果不存在则插入 - }); - return setResult; - } - - async get({ surveyPath, key, type }) { - const surveyKeyStore = await mongo.getCollection({ collectionName: 'surveyKeyStore' }); - const surveyKeyStoreData = await surveyKeyStore.findOne({ - key, - surveyPath, - type - }); - return surveyKeyStoreData?.data; - } - - async getAll({ surveyPath, keyList, type }) { - const surveyKeyStore = await mongo.getCollection({ collectionName: 'surveyKeyStore' }); - const res = await surveyKeyStore.find({ - key: { $in: keyList }, - surveyPath, - type - }).toArray(); - const surveyKeyStoreData : Array = res.map(doc => { - return { - key: doc.key, - surveyPath: doc.surveyPath, - type: doc.type, - data: doc.data, - createDate: doc.createDate, - updateDate: doc.updateDate, - }; - }); - return this.getKeyStoreResult(surveyKeyStoreData); - } - -} - -export const surveyKeyStoreService = new SurveyKeyStoreService(); \ No newline at end of file diff --git a/server/src/apps/surveyPublish/service/surveyPublishService.ts b/server/src/apps/surveyPublish/service/surveyPublishService.ts deleted file mode 100644 index 7c94e130..00000000 --- a/server/src/apps/surveyPublish/service/surveyPublishService.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { mongo } from '../db/mongo'; -import { surveyKeyStoreService } from './surveyKeyStoreService'; -import { CommonError } from '../../../types/index'; - -class SurveyPublishService { - async get({ surveyPath }: { surveyPath: string }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyMetaData = await surveyMeta.findOne({ surveyPath }); - if (!surveyMetaData) { - throw new CommonError('该问卷已不存在'); - } - const surveyMetaRes = mongo.convertId2StringByDoc(surveyMetaData); - const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' }); - const surveyPublishData = await surveyPublish.findOne({ pageId: surveyMetaRes._id.toString() }, { sort: { createDate: -1 } }); - if (!surveyPublishData) { - throw new CommonError('该问卷未发布'); - } - const surveyPublishRes = mongo.convertId2StringByDoc(surveyPublishData); - return { - surveyMetaRes, - surveyPublishRes - }; - } - - async queryVote({ surveyPath, voteKeyList }: { surveyPath: string, voteKeyList: Array }) { - return await surveyKeyStoreService.getAll({ surveyPath, keyList: voteKeyList, type: 'vote' }); - } - -} - -export const surveyPublishService = new SurveyPublishService(); \ No newline at end of file diff --git a/server/src/apps/surveyPublish/service/surveySubmitService.ts b/server/src/apps/surveyPublish/service/surveySubmitService.ts deleted file mode 100644 index d17915d9..00000000 --- a/server/src/apps/surveyPublish/service/surveySubmitService.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { mongo } from '../db/mongo'; -import { getStatusObject, randomCode } from '../utils/index'; -import { SURVEY_STATUS, CommonError } from '../../../types/index'; -import { surveyKeyStoreService } from './surveyKeyStoreService'; -import { getConfig } from '../config/index'; -import * as CryptoJS from 'crypto-js'; -import * as aes from 'crypto-js/aes'; -import * as moment from 'moment'; -import { keyBy } from 'lodash'; -import { rpcInvote } from '../../../rpc'; - -const config = getConfig(); - -class SurveySubmitService { - - async addSessionData(data) { - const surveySession = await mongo.getCollection({ collectionName: 'surveySession' }); - const surveySessionRes = await surveySession.insertOne({ - data, - expireDate: Date.now() + config.session.expireTime - }); - return { - sessionId: surveySessionRes.insertedId.toString(), - ...data - }; - } - - async getSessionData(sessionId) { - const surveySession = await mongo.getCollection({ collectionName: 'surveySession' }); - const sessionObjectId = mongo.getObjectIdByStr(sessionId); - const surveySessionRes = await surveySession.findOne({ _id: sessionObjectId }); - await surveySession.deleteMany({ expireDate: { $lt: Date.now() } }); - return { sessionId, data: surveySessionRes.data }; - } - - async getEncryptInfo() { - const encryptType = config.encrypt.type; - let data = {}; - if (encryptType === 'aes') { - data = await this.addSessionData({ - code: randomCode(config.encrypt.aesCodelength) - }); - } - return { - encryptType, - data - }; - } - - async submit({ surveySubmitData }) { - const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' }); - const surveyMetaRes = mongo.convertId2StringByDoc( - await surveyMeta.findOne({ surveyPath: surveySubmitData.surveyPath }) - ); - if (!surveyMetaRes) { - throw new CommonError('该问卷已不存在,无法提交'); - } - const pageId = surveyMetaRes._id.toString(); - const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' }); - const publishConf = await surveyPublish.findOne({ pageId }); - const surveySubmit = await mongo.getCollection({ collectionName: 'surveySubmit' }); - if (surveySubmitData.encryptType === 'base64') { - surveySubmitData.data = JSON.parse(decodeURIComponent(Buffer.from(surveySubmitData.data, 'base64').toString())); - } else if (surveySubmitData.encryptType === 'aes') { - const sessionData = await this.getSessionData(surveySubmitData.sessionId); - surveySubmitData.data = JSON.parse(decodeURIComponent(aes.decrypt(surveySubmitData.data, sessionData.data.code).toString(CryptoJS.enc.Utf8))); - } else { - surveySubmitData.data = JSON.parse(surveySubmitData.data); - } - // 提交时间限制 - const begTime = publishConf?.code?.baseConf?.begTime || 0; - const endTime = publishConf?.code?.baseConf?.endTime || 0; - if (begTime && endTime) { - const nowStamp = Date.now(); - const begTimeStamp = new Date(begTime).getTime(); - const endTimeStamp = new Date(endTime).getTime(); - if (nowStamp < begTimeStamp || nowStamp > endTimeStamp) { - throw new CommonError('不在答题有效期内'); - } - } - // 提交时间段限制 - const answerBegTime = publishConf?.code?.baseConf?.answerBegTime || '00:00:00'; - const answerEndTime = publishConf?.code?.baseConf?.answerEndTime || '23:59:59'; - if (answerBegTime && answerEndTime) { - const nowStamp = Date.now(); - const ymdString = moment().format('YYYY-MM-DD'); - const answerBegTimeStamp = new Date(`${ymdString} ${answerBegTime}`).getTime(); - const answerEndTimeStamp = new Date(`${ymdString} ${answerEndTime}`).getTime(); - if (nowStamp < answerBegTimeStamp || nowStamp > answerEndTimeStamp) { - throw new CommonError('不在答题时段内'); - } - } - // 提交总数限制 - const tLimit = publishConf?.code?.baseConf?.tLimit || 0; - if (tLimit > 0) { - // 提升性能可以使用redis - const nowSubmitCount = await surveySubmit.countDocuments({ surveyPath: surveySubmitData.surveyPath }) || 0; - if (nowSubmitCount >= tLimit) { - throw new CommonError('超出提交总数限制'); - } - } - const dataList = publishConf?.code?.dataConf?.dataList || []; - const dataListMap = keyBy(dataList, 'field'); - - const surveySubmitDataKeys = Object.keys(surveySubmitData.data); - - const secretKeys = []; - - for (const field of surveySubmitDataKeys) { - const configData = dataListMap[field]; - const value = surveySubmitData.data[field]; - const values = Array.isArray(value) ? value : [value]; - - if (configData && /vote/.exec(configData.type)) { - // 投票信息保存 - const voteData = (await surveyKeyStoreService.get({ surveyPath: surveySubmitData.surveyPath, key: field, type: 'vote' })) || { total: 0 }; - voteData.total++; - for (const val of values) { - if (!voteData[val]) { - voteData[val] = 1; - } else { - voteData[val]++; - } - } - await surveyKeyStoreService.set({ surveyPath: surveySubmitData.surveyPath, key: field, data: voteData, type: 'vote' }); - } - // 检查敏感数据,对敏感数据进行加密存储 - let isSecret = false; - for (const val of values) { - if (rpcInvote('security.isDataSensitive', val)) { - isSecret = true; - break; - } - } - if (isSecret) { - secretKeys.push(field); - surveySubmitData.data[field] = Array.isArray(value) ? value.map(item => rpcInvote('security.encryptData', item)) : rpcInvote('security.encryptData', value); - } - - } - - surveySubmitData.secretKeys = secretKeys; - - // 提交问卷 - const surveySubmitRes = await surveySubmit.insertOne({ - ...surveySubmitData, - pageId, - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - createDate: Date.now() - }); - return surveySubmitRes; - } -} - -export const surveySubmitService = new SurveySubmitService(); \ No newline at end of file diff --git a/server/src/apps/surveyPublish/utils/index.ts b/server/src/apps/surveyPublish/utils/index.ts deleted file mode 100644 index d0e062b5..00000000 --- a/server/src/apps/surveyPublish/utils/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SURVEY_STATUS, CommonError } from '../../../types/index'; -import * as Joi from 'joi'; - -export function getStatusObject({ status }: { status: SURVEY_STATUS }) { - return { - status, - id: status, - date: Date.now(), - }; -} -export function getValidateValue(validationResult: Joi.ValidationResult): T { - if (validationResult.error) { - throw new CommonError(validationResult.error.details.map(e => e.message).join()); - } - return validationResult.value; -} - -export function randomCode(length) { - const charList: Array = []; - for (let i = 0; i < length; i++) { - charList.push(Math.floor(Math.random() * 16).toString(16)); - } - return charList.join(''); -} \ No newline at end of file diff --git a/server/src/apps/ui/index.ts b/server/src/apps/ui/index.ts deleted file mode 100644 index edb95db9..00000000 --- a/server/src/apps/ui/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { SurveyApp, SurveyServer } from '../../decorator'; -import { createReadStream } from 'fs'; -import * as path from 'path'; - -@SurveyApp('') -export default class UI { - @SurveyServer({ type: 'http', method: 'get', routerName: '/render/(.*)' }) - async render({ res }) { - const filePath = path.join(__dirname, 'public', 'render.html'); - res.type = path.extname(filePath); - return createReadStream(filePath); - } - @SurveyServer({ type: 'http', method: 'get', routerName: '/management/(.*)' }) - async management({ res }) { - const filePath = path.join(__dirname, 'public', 'management.html'); - res.type = path.extname(filePath); - return createReadStream(filePath); - } - @SurveyServer({ type: 'http', method: 'get', routerName: '/' }) - async index({ res }) { - const filePath = path.join(__dirname, 'public', 'management.html'); - res.type = path.extname(filePath); - return createReadStream(filePath); - } -} \ No newline at end of file diff --git a/server/src/apps/user/config/index.ts b/server/src/apps/user/config/index.ts deleted file mode 100644 index 040c240b..00000000 --- a/server/src/apps/user/config/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { mongo, jwt } from '../../../config'; - -export function getConfig() { - return { - mongo, - jwt, - }; -} \ No newline at end of file diff --git a/server/src/apps/user/db/mongo.ts b/server/src/apps/user/db/mongo.ts deleted file mode 100644 index d0885a09..00000000 --- a/server/src/apps/user/db/mongo.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { getConfig } from '../config/index'; -import MongoService from '../../../utils/mongoService'; - -const config = getConfig(); - -export const mongo = new MongoService({ url: config.mongo.url, dbName: config.mongo.dbName }); diff --git a/server/src/apps/user/index.ts b/server/src/apps/user/index.ts deleted file mode 100644 index 0c133b85..00000000 --- a/server/src/apps/user/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { SurveyApp, SurveyServer } from '../../decorator'; -import { Request, Response } from 'koa'; -import * as Joi from 'joi'; -import { userService } from './service/userService'; -import { captchaService } from './service/captchaService'; -import { getValidateValue } from './utils/index'; - -import { CommonError } from '../../types/index'; - -@SurveyApp('/api/user') -export default class User { - @SurveyServer({ type: 'http', method: 'post', routerName: '/register' }) - async register({ req }: { req: Request, res: Response }) { - const userInfo = getValidateValue(Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), - captchaId: Joi.string().required(), - captcha: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - const isCorrect = await captchaService.checkCaptchaIsCorrect({ captcha: userInfo.captcha, id: userInfo.captchaId }); - if (!isCorrect) { - throw new CommonError('验证码不正确'); - } - const userRegisterRes = await userService.register({ - username: userInfo.username, - password: userInfo.password, - }); - // 删除验证码 - captchaService.deleteCaptcha({ id: userInfo.captchaId }); - return { - code: 200, - data: userRegisterRes, - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/login' }) - async login({ req }: { req: Request, res: Response }) { - const userInfo = getValidateValue(Joi.object({ - username: Joi.string().required(), - password: Joi.string().required(), - captchaId: Joi.string().required(), - captcha: Joi.string().required(), - }).validate(req.body, { allowUnknown: true })); - const isCorrect = await captchaService.checkCaptchaIsCorrect({ captcha: userInfo.captcha, id: userInfo.captchaId }); - if (!isCorrect) { - throw new CommonError('验证码不正确'); - } - const data = await userService.login({ - username: userInfo.username, - password: userInfo.password, - }); - // 删除验证码 - captchaService.deleteCaptcha({ id: userInfo.captchaId }); - return { - code: 200, - data, - }; - } - - @SurveyServer({ type: 'rpc' }) - async getUserByToken({ params, context }) { - const data = await userService.getUserByToken({ token: params.token }); - return { - result: data, - context, // 上下文主要是传递调用方信息使用,比如traceid - }; - } - - @SurveyServer({ type: 'http', method: 'post', routerName: '/captcha' }) - async refreshCaptcha({ req }) { - const captchaData = captchaService.createCaptcha(); - const res = await captchaService.addCaptchaData({ text: captchaData.text }); - if (req.body && req.body.captchaId) { - // 删除验证码 - captchaService.deleteCaptcha({ id: req.body.captchaId }); - } - return { - code: 200, - data: { - id: res.insertedId, - img: captchaData.data, - }, - }; - } -} \ No newline at end of file diff --git a/server/src/apps/user/service/captchaService.ts b/server/src/apps/user/service/captchaService.ts deleted file mode 100644 index 75599fc5..00000000 --- a/server/src/apps/user/service/captchaService.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { mongo } from '../db/mongo'; -import { create } from 'svg-captcha'; -class CaptchaService { - - createCaptcha() { - return create({ - size: 4, // 验证码长度 - ignoreChars: '0o1i', // 忽略字符 - noise: 3, // 干扰线数量 - color: true, // 启用彩色 - background: '#f0f0f0', // 背景色 - }); - } - - async addCaptchaData({ text }) { - const captchaDb = await mongo.getCollection({ collectionName: 'captcha' }); - const addRes = await captchaDb.insertOne({ - text, - }); - return addRes; - } - - async checkCaptchaIsCorrect({ captcha, id }) { - const captchaDb = await mongo.getCollection({ collectionName: 'captcha' }); - const captchaData = await captchaDb.findOne({ - _id: mongo.getObjectIdByStr(id), - }); - return captcha.toLowerCase() === captchaData?.text?.toLowerCase(); - } - - async deleteCaptcha({ id }) { - const captchaDb = await mongo.getCollection({ collectionName: 'captcha' }); - const _id = mongo.getObjectIdByStr(id); - await captchaDb.deleteOne({ - _id - }); - } -} - -export const captchaService = new CaptchaService(); diff --git a/server/src/apps/user/service/userService.ts b/server/src/apps/user/service/userService.ts deleted file mode 100644 index 96d25867..00000000 --- a/server/src/apps/user/service/userService.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - verify as jwtVerify, - sign as jwtSign -} from 'jsonwebtoken'; -import { - createHash -} from 'crypto'; -import { mongo } from '../db/mongo'; -import { getStatusObject } from '../utils/index'; -import { SURVEY_STATUS, CommonError } from '../../../types/index'; -import { getConfig } from '../config/index'; -const config = getConfig(); - - -class UserService { - - hash256(text) { - return createHash('sha256').update(text).digest('hex'); - } - - - getToken(userInfo) { - return jwtSign(userInfo, config.jwt.secret, { expiresIn: config.jwt.expiresIn }); - } - - async register(userInfo: { username: string, password: string }) { - const user = await mongo.getCollection({ collectionName: 'user' }); - const userRes = await user.findOne({ - username: userInfo.username, - }); - if (userRes) { - throw new CommonError('该用户已存在'); - } - const userInsertRes = await user.insertOne({ - username: userInfo.username, - password: this.hash256(userInfo.password), - curStatus: getStatusObject({ status: SURVEY_STATUS.new }), - createDate: Date.now() - }); - const token = this.getToken({ - _id: userInsertRes.insertedId.toString(), - username: userInfo.username - }); - return { userInsertRes, token, username: userInfo.username }; - } - - async login(userInfo: { username: string, password: string }) { - const user = await mongo.getCollection({ collectionName: 'user' }); - const userRes = await user.findOne({ - username: userInfo.username, - password: this.hash256(userInfo.password), - }); - if (!userRes) { - throw new CommonError('用户名或密码错误'); - } - const token = this.getToken({ - _id: userRes._id.toString(), - username: userInfo.username - }); - return { token, username: userInfo.username }; - } - - async getUserByToken(tokenInfo: { token: string }) { - let userInfo; - try { - userInfo = jwtVerify(tokenInfo.token, config.jwt.secret); - } catch (err) { - throw new CommonError('用户凭证无效或已过期', 403); - } - const user = await mongo.getCollection({ collectionName: 'user' }); - const userRes = await user.findOne({ - _id: mongo.getObjectIdByStr(userInfo._id), - }); - if (!userRes) { - throw new CommonError('用户已不存在'); - } - return mongo.convertId2StringByDoc(userRes); - } -} - -export const userService = new UserService(); \ No newline at end of file diff --git a/server/src/apps/user/utils/index.ts b/server/src/apps/user/utils/index.ts deleted file mode 100644 index 8233dbca..00000000 --- a/server/src/apps/user/utils/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { SURVEY_STATUS, CommonError } from '../../../types/index'; -import * as Joi from 'joi'; -export function getStatusObject({ status }: { status: SURVEY_STATUS }) { - return { - status, - id: status, - date: Date.now(), - }; -} -export function getValidateValue(validationResult: Joi.ValidationResult): T { - if (validationResult.error) { - throw new CommonError(validationResult.error.details.map(e => e.message).join()); - } - return validationResult.value; -} \ No newline at end of file diff --git a/server/src/config/index.ts b/server/src/config/index.ts deleted file mode 100644 index ccb267c8..00000000 --- a/server/src/config/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -const mongo = { - url: process.env.XIAOJU_SURVEY_MONGO_URL || 'mongodb://localhost:27017', - dbName: process.env.XIAOJU_SURVER_MONGO_DBNAME || 'xiaojuSurvey', -} - -const session = { - expireTime: parseInt(process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN) || 8 * 3600 * 1000 -} - -const encrypt = { - type: process.env.XIAOJU_SURVEY_ENCRYPT_TYPE || 'aes', - aesCodelength: parseInt(process.env.XIAOJU_SURVEY_ENCRYPT_TYPE_LEN) || 10 //aes密钥长度 -} - -const jwt = { - secret: process.env.XIAOJU_SURVEY_JWT_SECRET || 'xiaojuSurveyJwtSecret', - expiresIn: process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN || '8h' -} - - -export{ - mongo, - session, - encrypt, - jwt, -} diff --git a/server/src/decorator.ts b/server/src/decorator.ts deleted file mode 100644 index 8bcbae2e..00000000 --- a/server/src/decorator.ts +++ /dev/null @@ -1,29 +0,0 @@ -type ServerType = 'http' | 'websocket' | 'rpc' -export interface RouterOptions { - type: ServerType, - method?: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; - routerName?: string; -} - -export const surveyServerKey = Symbol('surveyServer'); // vm环境和worker环境上下文不一致导致不能使用Symbol -export const surveyAppKey = Symbol('surveyApp'); - -export function SurveyApp(routerName) { - return (target: unknown) => { - if (!target[surveyAppKey]) { - target[surveyAppKey] = routerName; - } - }; -} - -export function SurveyServer(options: RouterOptions) { - return function(target: unknown, propertyKey: string) { - if (!target[surveyServerKey]) { - target[surveyServerKey] = new Map(); - } - target[surveyServerKey].set( - propertyKey, - options - ); - }; -} \ No newline at end of file diff --git a/server/src/enums/encrypt.ts b/server/src/enums/encrypt.ts new file mode 100644 index 00000000..18ab045e --- /dev/null +++ b/server/src/enums/encrypt.ts @@ -0,0 +1,4 @@ +export enum ENCRYPT_TYPE { + AES = 'aes', + RSA = 'rsa', +} diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts new file mode 100644 index 00000000..f051c6d8 --- /dev/null +++ b/server/src/enums/exceptionCode.ts @@ -0,0 +1,18 @@ +export enum EXCEPTION_CODE { + AUTHTIFICATION_FAILED = 1001, // 没有权限 + PARAMETER_ERROR = 1002, // 参数有误 + USER_EXISTS = 2001, // 用户已存在 + USER_NOT_EXISTS = 2002, // 用户不存在 + NO_SURVEY_PERMISSION = 3001, // 没有问卷权限 + SURVEY_STATUS_TRANSFORM_ERROR = 3002, // 问卷状态转换报错 + SURVEY_TYPE_ERROR = 3003, // 问卷类型错误 + SURVEY_NOT_FOUND = 3004, // 问卷不存在 + SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容 + CAPTCHA_INCORRECT = 4001, // 验证码不正确 + + RESPONSE_SIGN_ERROR = 9001, // 签名不正确 + RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交 + RESPONSE_OVER_LIMIT = 9003, // 超出限制 + RESPONSE_SCHEMA_REMOVED = 9004, // 问卷已删除 + RESPONSE_DATA_DECRYPT_ERROR = 9005, // 问卷已删除 +} diff --git a/server/src/enums/index.ts b/server/src/enums/index.ts new file mode 100644 index 00000000..acaa0e33 --- /dev/null +++ b/server/src/enums/index.ts @@ -0,0 +1,15 @@ +// 状态类型 +export enum RECORD_STATUS { + NEW = 'new', // 新建 + EDITING = 'editing', // 编辑 + PAUSING = 'pausing', // 暂停 + PUBLISHED = 'published', // 发布 + REMOVED = 'removed', // 删除 + FORCE_REMOVED = 'forceRemoved', // 从回收站删除 +} + +// 历史类型 +export enum HISTORY_TYPE { + DAILY_HIS = 'dailyHis', //保存历史 + PUBLISH_HIS = 'publishHis', //发布历史 +} diff --git a/server/src/exceptions/authException.ts b/server/src/exceptions/authException.ts new file mode 100644 index 00000000..9ec67ea6 --- /dev/null +++ b/server/src/exceptions/authException.ts @@ -0,0 +1,7 @@ +import { HttpException } from './httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +export class AuthtificationException extends HttpException { + constructor(public readonly message: string) { + super(message, EXCEPTION_CODE.AUTHTIFICATION_FAILED); + } +} diff --git a/server/src/exceptions/httpException.ts b/server/src/exceptions/httpException.ts new file mode 100644 index 00000000..8ec0ee63 --- /dev/null +++ b/server/src/exceptions/httpException.ts @@ -0,0 +1,10 @@ +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +export class HttpException extends Error { + constructor( + public readonly message: string, + public readonly code: EXCEPTION_CODE, + ) { + super(message); + } +} diff --git a/server/src/exceptions/httpExceptions.filter.ts b/server/src/exceptions/httpExceptions.filter.ts new file mode 100644 index 00000000..a7432f02 --- /dev/null +++ b/server/src/exceptions/httpExceptions.filter.ts @@ -0,0 +1,33 @@ +// all-exceptions.filter.ts +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { HttpException } from './httpException'; + +@Catch(Error) +export class HttpExceptionsFilter implements ExceptionFilter { + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal Server Error'; + let code = 500; + + if (exception instanceof HttpException) { + status = HttpStatus.OK; // 非系统报错状态码为200 + message = exception.message; + code = exception.code; + } + + response.status(status).json({ + message, + code, + errmsg: exception.message, + }); + } +} diff --git a/server/src/exceptions/noSurveyPermissionException.ts b/server/src/exceptions/noSurveyPermissionException.ts new file mode 100644 index 00000000..17180ea1 --- /dev/null +++ b/server/src/exceptions/noSurveyPermissionException.ts @@ -0,0 +1,8 @@ +import { HttpException } from './httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +export class NoSurveyPermissionException extends HttpException { + constructor(public readonly message: string) { + super(message, EXCEPTION_CODE.NO_SURVEY_PERMISSION); + } +} diff --git a/server/src/exceptions/surveyNotFoundException.ts b/server/src/exceptions/surveyNotFoundException.ts new file mode 100644 index 00000000..f5238aa0 --- /dev/null +++ b/server/src/exceptions/surveyNotFoundException.ts @@ -0,0 +1,8 @@ +import { HttpException } from './httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +export class SurveyNotFoundException extends HttpException { + constructor(public readonly message: string) { + super(message, EXCEPTION_CODE.SURVEY_NOT_FOUND); + } +} diff --git a/server/src/guards/authtication.ts b/server/src/guards/authtication.ts new file mode 100644 index 00000000..69c33030 --- /dev/null +++ b/server/src/guards/authtication.ts @@ -0,0 +1,40 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { UserService } from '../modules/auth/services/user.service'; +import { verify } from 'jsonwebtoken'; +import { ConfigService } from '@nestjs/config'; +import { AuthtificationException } from '../exceptions/authException'; + +@Injectable() +export class Authtication implements CanActivate { + constructor( + private readonly userService: UserService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = request.headers.authorization?.split(' ')[1]; + + if (!token) { + throw new AuthtificationException('未登录'); + } + + let decoded; + try { + decoded = verify( + token, + this.configService.get('XIAOJU_SURVEY_JWT_SECRET'), + ); + } catch (err) { + throw new AuthtificationException('用户凭证错误'); + } + const user = await this.userService.getUserByUsername(decoded.username); // 从数据库中查找用户 + + if (!user) { + throw new AuthtificationException('用户不存在'); + } + + request.user = user; // 将用户信息存储在请求中 + return true; + } +} diff --git a/server/src/index.ts b/server/src/index.ts deleted file mode 100644 index 0331d2cc..00000000 --- a/server/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as os from 'os'; -import * as Koa from 'koa'; -import * as KoaBodyparser from 'koa-bodyparser'; -import * as KoaStatic from 'koa-static'; -import * as logger from 'koa-pino-logger'; -import { initRouter } from './router'; -import { outputCatch } from './middleware/outputCatch'; -import * as path from 'path'; - -async function main() { - const app = new Koa(); - app.use(outputCatch({ showErrorStack: true })); - app.use(logger()); - app.use(KoaBodyparser({ - formLimit: '30mb', - jsonLimit: '30mb', - textLimit: '30mb', - xmlLimit: '30mb', - })); - - await initRouter(app); - - app.use(KoaStatic(path.join(__dirname, './apps/ui/public'))); - - const port = process.env.PORT || 3000; - app.listen(port); - process.stdout.write(`${os.EOL}server run: http://127.0.0.1:${port} ${os.EOL}`); -} - -main(); diff --git a/server/src/types/survey.ts b/server/src/interfaces/survey.ts similarity index 91% rename from server/src/types/survey.ts rename to server/src/interfaces/survey.ts index a0aa688a..a281b8ce 100644 --- a/server/src/types/survey.ts +++ b/server/src/interfaces/survey.ts @@ -1,3 +1,5 @@ +// 问卷配置内容定义 + export interface TitleConfig { mainTitle: string; subTitle: string; @@ -9,16 +11,12 @@ export interface BannerConfig { postImg: string; } +// 问卷头部内容:标题和头图 export interface BannerConf { titleConfig: TitleConfig; bannerConfig: BannerConfig; } -export interface TimeStep { - hour: number; - min: number; -} - export interface NPS { leftText: string; rightText: string; @@ -49,9 +47,7 @@ export interface DataItem { checked: boolean; minNum: string; maxNum: string; - maxPhotos: number; star: number; - timeStep: TimeStep; nps: NPS; placeholderDesc: string; addressType: number; @@ -102,7 +98,9 @@ export interface SubmitConf { export interface BaseConf { begTime: string; endTime: string; - tLimit: string; + answerBegTime: string; + answerEndTime: string; + tLimit: number; language: string; } @@ -111,7 +109,7 @@ export interface SkinConf { inputBgColor: string; } -export interface ParsedData { +export interface SurveySchemaInterface { bannerConf: BannerConf; dataConf: DataConf; submitConf: SubmitConf; diff --git a/server/src/logger/index.ts b/server/src/logger/index.ts new file mode 100644 index 00000000..c54ce1b4 --- /dev/null +++ b/server/src/logger/index.ts @@ -0,0 +1,57 @@ +import * as log4js from 'log4js'; +import moment from 'moment'; + +const log4jsLogger = log4js.getLogger(); + +export class Logger { + private traceId: string = ''; + private inited = false; + + init(config: { filename: string }) { + if (this.inited) { + return; + } + log4js.configure({ + appenders: { + app: { + type: 'dateFile', + filename: config.filename || './logs/app.log', + pattern: 'yyyy-MM-dd', + alwaysIncludePattern: true, + numBackups: 7, + layout: { + type: 'pattern', + pattern: '%m', + }, + }, + }, + categories: { + default: { appenders: ['app'], level: 'trace' }, + }, + }); + } + + setTraceId(traceId: string) { + this.traceId = traceId; + } + + _log(message, options: { dltag?: string; level: string }) { + const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS'); + const level = options.level; + const dltag = options.dltag ? `${options.dltag}||` : ''; + const traceId = this.traceId ? `traceid=${this.traceId}||` : ''; + return log4jsLogger[level]( + `[${datetime}][${level.toUpperCase()}]${dltag}${traceId}${message}`, + ); + } + + info(message, options = { dltag: '' }) { + return this._log(message, { ...options, level: 'info' }); + } + + error(message, options = { dltag: '' }) { + return this._log(message, { ...options, level: 'error' }); + } +} + +export default new Logger(); diff --git a/server/src/logger/logger.provider.ts b/server/src/logger/logger.provider.ts new file mode 100644 index 00000000..513b7690 --- /dev/null +++ b/server/src/logger/logger.provider.ts @@ -0,0 +1,8 @@ +import { Provider } from '@nestjs/common'; + +import logger, { Logger } from './index'; + +export const LoggerProvider: Provider = { + provide: Logger, + useValue: logger, +}; diff --git a/server/src/logger/util.ts b/server/src/logger/util.ts new file mode 100644 index 00000000..85906560 --- /dev/null +++ b/server/src/logger/util.ts @@ -0,0 +1,15 @@ +import { customAlphabet } from 'nanoid'; +const traceIdAlphabet = 'abcdef0123456789'; + +let count = 0; + +const getCountStr = () => { + count++; + return count.toString().padStart(8, '0'); +}; + +const getRandom = customAlphabet(traceIdAlphabet, 10); + +export const genTraceId = (): string => { + return getRandom() + Math.round(Date.now() / 1000).toString() + getCountStr(); +}; diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 00000000..13cad38c --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,8 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); +} +bootstrap(); diff --git a/server/src/middleware/outputCatch.ts b/server/src/middleware/outputCatch.ts deleted file mode 100644 index 0e973930..00000000 --- a/server/src/middleware/outputCatch.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function outputCatch({ showErrorStack }: { showErrorStack: boolean }) { - return async function(ctx, next) { - try { - await next(); - } catch (err) { - const outputData = { ...err }; - if (showErrorStack) { - outputData.stack = err.stack; - } - return ctx.body = outputData; - } - }; -} \ No newline at end of file diff --git a/server/src/middlewares/logRequest.middleware.ts b/server/src/middlewares/logRequest.middleware.ts new file mode 100644 index 00000000..2302110f --- /dev/null +++ b/server/src/middlewares/logRequest.middleware.ts @@ -0,0 +1,38 @@ +// logger.middleware.ts +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { Logger } from '../logger/index'; // 替换为你实际的logger路径 +import { genTraceId } from '../logger/util'; + +@Injectable() +export class LogRequestMiddleware implements NestMiddleware { + constructor(private readonly logger: Logger) {} + + use(req: Request, res: Response, next: NextFunction) { + const { method, originalUrl, ip } = req; + const userAgent = req.get('user-agent') || ''; + const startTime = Date.now(); + const traceId = genTraceId(); + this.logger.setTraceId(traceId); + const query = JSON.stringify(req.query); + const body = JSON.stringify(req.body); + this.logger.info( + `method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`, + { + dltag: 'request_in', + }, + ); + + res.once('finish', () => { + const duration = Date.now() - startTime; + this.logger.info( + `status=${res.statusCode.toString()}||duration=${duration}ms`, + { + dltag: 'request_out', + }, + ); + }); + + next(); + } +} diff --git a/server/src/models/captcha.entity.ts b/server/src/models/captcha.entity.ts new file mode 100644 index 00000000..f3da9b6d --- /dev/null +++ b/server/src/models/captcha.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + Column, + Index, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; + +@Entity({ name: 'captcha' }) +export class Captcha { + @Index({ + expireAfterSeconds: + new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, + }) + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + text: string; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/clientEncrypt.entity.ts b/server/src/models/clientEncrypt.entity.ts new file mode 100644 index 00000000..d67ed934 --- /dev/null +++ b/server/src/models/clientEncrypt.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + Column, + Index, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; +import { ENCRYPT_TYPE } from '../enums/encrypt'; + +@Entity({ name: 'clientEncrypt' }) +export class ClientEncrypt { + @Index({ + expireAfterSeconds: + new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, + }) + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column('jsonb') + data: { + secretKey?: string; // aes加密的密钥 + publicKey?: string; // rsa加密的公钥 + privateKey?: string; // rsa加密的私钥 + }; + + @Column() + type: ENCRYPT_TYPE; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/counter.entity.ts b/server/src/models/counter.entity.ts new file mode 100644 index 00000000..cf9f3959 --- /dev/null +++ b/server/src/models/counter.entity.ts @@ -0,0 +1,62 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; + +@Entity({ name: 'counter' }) +export class Counter { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + key: string; + + @Column() + surveyPath: string; + + @Column() + type: string; + + @Column('jsonb') + data: Record; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/responseSchema.entity.ts b/server/src/models/responseSchema.entity.ts new file mode 100644 index 00000000..5c076723 --- /dev/null +++ b/server/src/models/responseSchema.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; +import { SurveySchemaInterface } from '../interfaces/survey'; + +@Entity({ name: 'surveyPublish' }) +export class ResponseSchema { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + title: string; + + @Column() + surveyPath: string; + + @Column('jsonb') + code: SurveySchemaInterface; + + @Column() + pageId: string; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/surveyConf.entity.ts b/server/src/models/surveyConf.entity.ts new file mode 100644 index 00000000..499b51ed --- /dev/null +++ b/server/src/models/surveyConf.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; +import { SurveySchemaInterface } from '../interfaces/survey'; + +@Entity({ name: 'surveyConf' }) +export class SurveyConf { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column({ type: 'bigint' }) + createDate: number; + + @Column({ type: 'bigint' }) + updateDate: number; + + @Column('jsonb') + code: SurveySchemaInterface; + + @Column() + pageId: string; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/surveyHistory.entity.ts b/server/src/models/surveyHistory.entity.ts new file mode 100644 index 00000000..bd721660 --- /dev/null +++ b/server/src/models/surveyHistory.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { HISTORY_TYPE, RECORD_STATUS } from '../enums'; +import { SurveySchemaInterface } from '../interfaces/survey'; + +@Entity({ name: 'surveyHistory' }) +export class SurveyHistory { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + pageId: string; + + @Column() + type: HISTORY_TYPE; + + @Column('jsonb') + schema: SurveySchemaInterface; + + @Column('jsonb') + operator: { + username: string; + _id: string; + }; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/surveyMeta.entity.ts b/server/src/models/surveyMeta.entity.ts new file mode 100644 index 00000000..7cd78840 --- /dev/null +++ b/server/src/models/surveyMeta.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; + +@Entity({ name: 'surveyMeta' }) +export class SurveyMeta { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + title: string; + + @Column() + remark: string; + + @Column() + surveyType: string; + + @Column() + surveyPath: string; + + @Column() + creator: string; + + @Column() + owner: string; + + @Column() + createMethod: string; + + @Column() + createFrom: string; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/surveyResponse.entity.ts b/server/src/models/surveyResponse.entity.ts new file mode 100644 index 00000000..0e6f660a --- /dev/null +++ b/server/src/models/surveyResponse.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, + AfterLoad, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; +import pluginManager from '../plugins/pluginManager'; + +@Entity({ name: 'surveySubmit' }) +export class SurveyResponse { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + pageId: string; + + @Column() + surveyPath: string; + + @Column('jsonb') + data: Record; + + @Column() + difTime: number; + + @Column() + clientTime: number; + + @Column('jsonb') + secretKeys: Array; + + @Column('jsonb') + optionTextAndId: Record; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + pluginManager.triggerHook('beforeResponseDataCreate', this); + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } + + @AfterLoad() + onDataLoaded() { + pluginManager.triggerHook('afterResponseDataReaded', this); + } +} diff --git a/server/src/models/user.entity.ts b/server/src/models/user.entity.ts new file mode 100644 index 00000000..b0ffbd32 --- /dev/null +++ b/server/src/models/user.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; + +@Entity({ name: 'user' }) +export class User { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @Column() + username: string; + + @Column() + password: string; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/models/word.entity.ts b/server/src/models/word.entity.ts new file mode 100644 index 00000000..8610f1e9 --- /dev/null +++ b/server/src/models/word.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + Column, + ObjectIdColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from '../enums'; + +@Entity({ name: 'word' }) +export class Word { + @ObjectIdColumn() + _id: ObjectId; + + @Column() + text: string; + + @Column() + type: string; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS; + date: number; + }>; + + @Column() + createDate: number; + + @Column() + updateDate: number; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + this.createDate = now; + this.updateDate = now; + } + + @BeforeUpdate() + onUpdate() { + this.updateDate = Date.now(); + } +} diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts new file mode 100644 index 00000000..8557584a --- /dev/null +++ b/server/src/modules/auth/auth.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './services/user.service'; +import { AuthService } from './services/auth.service'; +import { CaptchaService } from './services/captcha.service'; + +import { AuthController } from './controllers/auth.controller'; + +import { User } from 'src/models/user.entity'; +import { Captcha } from 'src/models/captcha.entity'; + +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], + controllers: [AuthController], + providers: [UserService, AuthService, CaptchaService], + exports: [UserService], +}) +export class AuthModule {} diff --git a/server/src/modules/auth/controllers/auth.controller.spec.ts b/server/src/modules/auth/controllers/auth.controller.spec.ts new file mode 100644 index 00000000..7b32ad6b --- /dev/null +++ b/server/src/modules/auth/controllers/auth.controller.spec.ts @@ -0,0 +1,152 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { AuthController } from './auth.controller'; +import { UserService } from '../services/user.service'; +import { CaptchaService } from '../services/captcha.service'; +import { AuthService } from '../services/auth.service'; + +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { ObjectId } from 'mongodb'; +import { User } from 'src/models/user.entity'; + +jest.mock('../services/captcha.service'); +jest.mock('../services/auth.service'); +jest.mock('../services/user.service'); + +describe('AuthController', () => { + let controller: AuthController; + let userService: UserService; + let captchaService: CaptchaService; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], + controllers: [AuthController], + providers: [UserService, CaptchaService, ConfigService, AuthService], + }).compile(); + + controller = module.get(AuthController); + userService = module.get(UserService); + captchaService = module.get(CaptchaService); + authService = module.get(AuthService); + }); + + describe('register', () => { + it('should register a user and return a token when captcha is correct', async () => { + const mockUserInfo = { + username: 'testUser', + password: 'testPassword', + captchaId: 'testCaptchaId', + captcha: 'testCaptcha', + }; + + jest + .spyOn(captchaService, 'checkCaptchaIsCorrect') + .mockResolvedValue(true); + jest.spyOn(userService, 'createUser').mockResolvedValue( + Promise.resolve({ + username: 'testUser', + _id: new ObjectId(), + } as User), + ); + jest.spyOn(authService, 'generateToken').mockResolvedValue('testToken'); + + const result = await controller.register(mockUserInfo); + + expect(result).toEqual({ + code: 200, + data: { + token: 'testToken', + username: 'testUser', + }, + }); + }); + + it('should throw HttpException with CAPTCHA_INCORRECT code when captcha is incorrect', async () => { + const mockUserInfo = { + username: 'testUser', + password: 'testPassword', + captchaId: 'testCaptchaId', + captcha: 'testCaptcha', + }; + + jest + .spyOn(captchaService, 'checkCaptchaIsCorrect') + .mockResolvedValue(false); + + await expect(controller.register(mockUserInfo)).rejects.toThrow( + new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT), + ); + }); + }); + + describe('login', () => { + it('should login a user and return a token when captcha is correct', async () => { + const mockUserInfo = { + username: 'testUser', + password: 'testPassword', + captchaId: 'testCaptchaId', + captcha: 'testCaptcha', + }; + + jest + .spyOn(captchaService, 'checkCaptchaIsCorrect') + .mockResolvedValue(true); + jest.spyOn(userService, 'getUser').mockResolvedValue( + Promise.resolve({ + username: 'testUser', + _id: new ObjectId(), + } as User), + ); + jest.spyOn(authService, 'generateToken').mockResolvedValue('testToken'); + + const result = await controller.login(mockUserInfo); + + expect(result).toEqual({ + code: 200, + data: { + token: 'testToken', + username: 'testUser', + }, + }); + }); + + it('should throw HttpException with CAPTCHA_INCORRECT code when captcha is incorrect', async () => { + const mockUserInfo = { + username: 'testUser', + password: 'testPassword', + captchaId: 'testCaptchaId', + captcha: 'testCaptcha', + }; + + jest + .spyOn(captchaService, 'checkCaptchaIsCorrect') + .mockResolvedValue(false); + + await expect(controller.login(mockUserInfo)).rejects.toThrow( + new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT), + ); + }); + + it('should throw HttpException with USER_NOT_EXISTS code when user is not found', async () => { + const mockUserInfo = { + username: 'testUser', + password: 'testPassword', + captchaId: 'testCaptchaId', + captcha: 'testCaptcha', + }; + + jest + .spyOn(captchaService, 'checkCaptchaIsCorrect') + .mockResolvedValue(true); + jest.spyOn(userService, 'getUser').mockResolvedValue(null); + + await expect(controller.login(mockUserInfo)).rejects.toThrow( + new HttpException('用户名或密码错误', EXCEPTION_CODE.USER_NOT_EXISTS), + ); + }); + }); +}); diff --git a/server/src/modules/auth/controllers/auth.controller.ts b/server/src/modules/auth/controllers/auth.controller.ts new file mode 100644 index 00000000..45db77e6 --- /dev/null +++ b/server/src/modules/auth/controllers/auth.controller.ts @@ -0,0 +1,156 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { UserService } from '../services/user.service'; +import { CaptchaService } from '../services/captcha.service'; // 假设你的验证码服务在这里 +import { ConfigService } from '@nestjs/config'; +import { AuthService } from '../services/auth.service'; + +import { HttpException } from 'src/exceptions/httpException'; + +import { create } from 'svg-captcha'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +@Controller('/api/auth') +export class AuthController { + constructor( + private readonly userService: UserService, + readonly captchaService: CaptchaService, + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) {} + + @Post('/register') + @HttpCode(200) + async register( + @Body() + userInfo: { + username: string; + password: string; + captchaId: string; + captcha: string; + }, + ) { + const isCorrect = await this.captchaService.checkCaptchaIsCorrect({ + captcha: userInfo.captcha, + id: userInfo.captchaId, + }); + + if (!isCorrect) { + throw new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT); + } + + const user = await this.userService.createUser({ + username: userInfo.username, + password: userInfo.password, + }); + + const token = await this.authService.generateToken( + { + username: user.username, + _id: user._id.toString(), + }, + { + secret: this.configService.get('XIAOJU_SURVEY_JWT_SECRET'), + expiresIn: this.configService.get( + 'XIAOJU_SURVEY_JWT_EXPIRES_IN', + ), + }, + ); + // 验证过的验证码要删掉,防止被别人保存重复调用 + this.captchaService.deleteCaptcha(userInfo.captchaId); + return { + code: 200, + data: { + token, + username: user.username, + }, + }; + } + + @Post('/login') + @HttpCode(200) + async login( + @Body() + userInfo: { + username: string; + password: string; + captchaId: string; + captcha: string; + }, + ) { + const isCorrect = await this.captchaService.checkCaptchaIsCorrect({ + captcha: userInfo.captcha, + id: userInfo.captchaId, + }); + + if (!isCorrect) { + throw new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT); + } + + const user = await this.userService.getUser({ + username: userInfo.username, + password: userInfo.password, + }); + if (user === null) { + throw new HttpException( + '用户名或密码错误', + EXCEPTION_CODE.USER_NOT_EXISTS, + ); + } + let token; + try { + token = await this.authService.generateToken( + { + username: user.username, + _id: user._id.toString(), + }, + { + secret: this.configService.get('XIAOJU_SURVEY_JWT_SECRET'), + expiresIn: this.configService.get( + 'XIAOJU_SURVEY_JWT_EXPIRES_IN', + ), + }, + ); + // 验证过的验证码要删掉,防止被别人保存重复调用 + this.captchaService.deleteCaptcha(userInfo.captchaId); + } catch (error) { + throw new Error( + 'generateToken erro:' + + error.message + + this.configService.get('XIAOJU_SURVEY_JWT_SECRET') + + this.configService.get('XIAOJU_SURVEY_JWT_EXPIRES_IN'), + ); + } + + return { + code: 200, + data: { + token, + username: user.username, + }, + }; + } + + @Post('/captcha') + @HttpCode(200) + async getCaptcha(): Promise<{ + code: number; + data: { id: string; img: string }; + }> { + const captchaData = create({ + size: 4, // 验证码长度 + ignoreChars: '0o1i', // 忽略字符 + noise: 3, // 干扰线数量 + color: true, // 启用彩色 + background: '#f0f0f0', // 背景色 + }); + const res = await this.captchaService.createCaptcha(captchaData.text); + + return { + code: 200, + data: { + id: res._id.toString(), + img: captchaData.data, + }, + }; + } +} diff --git a/server/src/modules/auth/services/auth.service.spec.ts b/server/src/modules/auth/services/auth.service.spec.ts new file mode 100644 index 00000000..efc2c596 --- /dev/null +++ b/server/src/modules/auth/services/auth.service.spec.ts @@ -0,0 +1,33 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { sign } from 'jsonwebtoken'; + +jest.mock('jsonwebtoken'); + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + describe('generateToken', () => { + it('should generate token successfully', async () => { + const userData = { _id: 'mockUserId', username: 'mockUsername' }; + const tokenConfig = { + secret: 'mockSecretKey', + expiresIn: '8h', + }; + + await service.generateToken(userData, tokenConfig); + + expect(sign).toHaveBeenCalledWith(userData, tokenConfig.secret, { + expiresIn: tokenConfig.expiresIn, + }); + }); + }); +}); diff --git a/server/src/modules/auth/services/auth.service.ts b/server/src/modules/auth/services/auth.service.ts new file mode 100644 index 00000000..be750fe2 --- /dev/null +++ b/server/src/modules/auth/services/auth.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; + +import { sign } from 'jsonwebtoken'; + +@Injectable() +export class AuthService { + async generateToken( + { _id, username }: { _id: string; username: string }, + { secret, expiresIn }: { secret: string; expiresIn: string }, + ) { + return sign({ _id, username }, secret, { + expiresIn, + }); + } +} diff --git a/server/src/modules/auth/services/captcha.service.spec.ts b/server/src/modules/auth/services/captcha.service.spec.ts new file mode 100644 index 00000000..362a8f70 --- /dev/null +++ b/server/src/modules/auth/services/captcha.service.spec.ts @@ -0,0 +1,111 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CaptchaService } from './captcha.service'; +import { MongoRepository } from 'typeorm'; +import { Captcha } from 'src/models/captcha.entity'; +import { ObjectId } from 'mongodb'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +describe('CaptchaService', () => { + let service: CaptchaService; + let captchaRepository: MongoRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CaptchaService, + { + provide: getRepositoryToken(Captcha), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(CaptchaService); + captchaRepository = module.get>( + getRepositoryToken(Captcha), + ); + }); + + describe('createCaptcha', () => { + it('should create a captcha successfully', async () => { + const mockCaptchaText = 'xsfd'; + jest.spyOn(captchaRepository, 'create').mockImplementation((data) => { + return { + ...data, + } as Captcha; + }); + jest.spyOn(captchaRepository, 'save').mockImplementation((data) => { + return Promise.resolve({ + _id: new ObjectId(), + ...data, + } as Captcha); + }); + + const result = await service.createCaptcha(mockCaptchaText); + + expect(result.text).toBe(mockCaptchaText); + expect(captchaRepository.create).toHaveBeenCalledWith({ + text: mockCaptchaText, + }); + }); + }); + + describe('getCaptcha', () => { + it('should get a captcha by ID successfully', async () => { + const mockCaptchaId = new ObjectId(); + const mockCaptcha = new Captcha(); + mockCaptcha._id = mockCaptchaId; + + jest + .spyOn(captchaRepository, 'findOne') + .mockImplementation(() => Promise.resolve(mockCaptcha)); + + const result = await service.getCaptcha(mockCaptchaId.toString()); + + expect(result).toBe(mockCaptcha); + expect(captchaRepository.findOne).toHaveBeenCalledWith({ + where: { _id: mockCaptchaId }, + }); + }); + }); + + describe('deleteCaptcha', () => { + it('should delete a captcha by ID successfully', async () => { + const mockCaptchaId = new ObjectId(); + + await service.deleteCaptcha(mockCaptchaId.toString()); + + expect(captchaRepository.delete).toHaveBeenCalledWith(mockCaptchaId); + }); + + // Add more test cases for different scenarios + }); + + describe('checkCaptchaIsCorrect', () => { + it('should check if captcha is correct successfully', async () => { + const mockCaptchaId = new ObjectId(); + const mockCaptcha = new Captcha(); + mockCaptcha._id = mockCaptchaId; + mockCaptcha.text = 'asfq'; + jest + .spyOn(captchaRepository, 'findOne') + .mockImplementation(() => Promise.resolve(mockCaptcha)); + + const mockCaptchaData = { + captcha: 'asfq', + id: mockCaptchaId.toString(), + }; + const result = await service.checkCaptchaIsCorrect(mockCaptchaData); + + expect(result).toBe(true); + expect(captchaRepository.findOne).toHaveBeenCalledWith({ + where: { _id: mockCaptchaId }, + }); + }); + }); +}); diff --git a/server/src/modules/auth/services/captcha.service.ts b/server/src/modules/auth/services/captcha.service.ts new file mode 100644 index 00000000..61cc97da --- /dev/null +++ b/server/src/modules/auth/services/captcha.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { Captcha } from 'src/models/captcha.entity'; +import { ObjectId } from 'mongodb'; + +@Injectable() +export class CaptchaService { + constructor( + @InjectRepository(Captcha) + private readonly captchaRepository: MongoRepository, + ) {} + + async createCaptcha(captchaText: string): Promise { + const captcha = this.captchaRepository.create({ + text: captchaText, + }); + + return this.captchaRepository.save(captcha); + } + + async getCaptcha(id: string): Promise { + return this.captchaRepository.findOne({ where: { _id: new ObjectId(id) } }); + } + + async deleteCaptcha(id: string): Promise { + await this.captchaRepository.delete(new ObjectId(id)); + } + + async checkCaptchaIsCorrect({ captcha, id }) { + const captchaData = await this.captchaRepository.findOne({ + where: { _id: new ObjectId(id) }, + }); + return captcha.toLowerCase() === captchaData?.text?.toLowerCase(); + } +} diff --git a/server/src/modules/auth/services/user.service.ts b/server/src/modules/auth/services/user.service.ts new file mode 100644 index 00000000..120722c9 --- /dev/null +++ b/server/src/modules/auth/services/user.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { User } from 'src/models/user.entity'; +import { createHash } from 'crypto'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private readonly userRepository: MongoRepository, + ) {} + + private hash256(text) { + return createHash('sha256').update(text).digest('hex'); + } + + async createUser(userInfo: { + username: string; + password: string; + }): Promise { + const existingUser = await this.userRepository.findOne({ + where: { username: userInfo.username }, + }); + + if (existingUser) { + throw new HttpException('该用户已存在', EXCEPTION_CODE.USER_EXISTS); + } + + const newUser = this.userRepository.create({ + username: userInfo.username, + password: this.hash256(userInfo.password), + }); + + return this.userRepository.save(newUser); + } + + async getUser(userInfo: { + username: string; + password: string; + }): Promise { + const user = await this.userRepository.findOne({ + where: { + username: userInfo.username, + password: this.hash256(userInfo.password), // Please handle password hashing here + }, + }); + + return user; + } + + async getUserByUsername(username) { + const user = await this.userRepository.findOne({ + where: { + username: username, + }, + }); + + return user; + } +} diff --git a/server/src/modules/survey/__test/contentSecurity.service.spec.ts b/server/src/modules/survey/__test/contentSecurity.service.spec.ts new file mode 100644 index 00000000..6eb47f42 --- /dev/null +++ b/server/src/modules/survey/__test/contentSecurity.service.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ContentSecurityService } from '../services/contentSecurity.service'; +import { Word } from 'src/models/word.entity'; + +describe('ContentSecurityService', () => { + let service: ContentSecurityService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContentSecurityService, + { + provide: getRepositoryToken(Word), + useValue: { + find: jest.fn().mockResolvedValue([ + { + text: '违禁词1', + }, + { + text: '违禁词2', + }, + ]), + create: jest.fn(), + save: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ContentSecurityService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('isForbiddenContent', () => { + it('should return true if text contains forbidden word', async () => { + const result = await service.isForbiddenContent({ + text: '这是违禁词1', + }); + + expect(result).toBe(true); + }); + + it('should return false if text does not contain forbidden word', async () => { + const result = await service.isForbiddenContent({ + text: '这句话不包含违禁词', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/server/src/modules/survey/__test/survey.controller.spec.ts b/server/src/modules/survey/__test/survey.controller.spec.ts new file mode 100644 index 00000000..9fd5888b --- /dev/null +++ b/server/src/modules/survey/__test/survey.controller.spec.ts @@ -0,0 +1,308 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SurveyController } from '../controllers/survey.controller'; +import { SurveyMetaService } from '../services/surveyMeta.service'; +import { SurveyConfService } from '../services/surveyConf.service'; +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; +import { ContentSecurityService } from '../services/contentSecurity.service'; +import { SurveyHistoryService } from '../services/surveyHistory.service'; +import { ObjectId } from 'mongodb'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { SurveyConf } from 'src/models/surveyConf.entity'; + +// Mock the services +jest.mock('../services/surveyMeta.service'); +jest.mock('../services/surveyConf.service'); +jest.mock('../../surveyResponse/services/responseScheme.service'); +jest.mock('../services/contentSecurity.service'); +jest.mock('../services/surveyHistory.service'); + +jest.mock('src/guards/authtication'); + +describe('SurveyController', () => { + let controller: SurveyController; + let surveyMetaService: SurveyMetaService; + let surveyConfService: SurveyConfService; + let responseSchemaService: ResponseSchemaService; + let contentSecurityService: ContentSecurityService; + let surveyHistoryService: SurveyHistoryService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SurveyController], + providers: [ + SurveyMetaService, + SurveyConfService, + ResponseSchemaService, + ContentSecurityService, + SurveyHistoryService, + ], + }).compile(); + + controller = module.get(SurveyController); + surveyMetaService = module.get(SurveyMetaService); + surveyConfService = module.get(SurveyConfService); + responseSchemaService = module.get( + ResponseSchemaService, + ); + contentSecurityService = module.get( + ContentSecurityService, + ); + surveyHistoryService = + module.get(SurveyHistoryService); + }); + + describe('getBannerData', () => { + it('should return banner data', async () => { + const result = await controller.getBannerData(); + + expect(result.code).toBe(200); + expect(result.data).toBeDefined(); + }); + }); + + describe('createSurvey', () => { + it('should create a survey and return the survey ID', async () => { + const surveyInfo = { + surveyType: 'normal', + remark: '问卷调研', + title: '问卷调研', + } as SurveyMeta; + const newId = new ObjectId(); + jest + .spyOn(surveyMetaService, 'createSurveyMeta') + .mockImplementation(() => { + const result = { + _id: newId, + } as SurveyMeta; + return Promise.resolve(result); + }); + jest + .spyOn(surveyConfService, 'createSurveyConf') + .mockImplementation( + (params: { + surveyId: string; + surveyType: string; + createMethod: string; + createFrom: string; + }) => { + const result = { + _id: new ObjectId(), + pageId: params.surveyId, + code: {}, + } as SurveyConf; + return Promise.resolve(result); + }, + ); + + const result = await controller.createSurvey(surveyInfo, { + user: { username: 'testUser' }, + }); + + expect(result).toEqual({ + code: 200, + data: { + id: newId.toString(), + }, + }); + }); + + it('should create a new survey by copy', async () => { + const existsSurveyId = new ObjectId(); + const existsSurveyMeta = { + _id: existsSurveyId, + surveyType: 'exam', + owner: 'testUser', + } as SurveyMeta; + const params = { + surveyType: 'normal', + remark: '问卷调研', + title: '问卷调研', + createMethod: 'copy', + createFrom: existsSurveyId.toString(), + }; + jest + .spyOn(surveyMetaService, 'checkSurveyAccess') + .mockResolvedValue(Promise.resolve(existsSurveyMeta)); + + jest + .spyOn(surveyMetaService, 'createSurveyMeta') + .mockImplementation(() => { + const result = { + _id: new ObjectId(), + } as SurveyMeta; + return Promise.resolve(result); + }); + + const request = { user: { username: 'testUser' } }; // 模拟请求对象,根据实际情况进行调整 + const result = await controller.createSurvey(params, request); + expect(result?.data?.id).toBeDefined(); + }); + }); + + describe('updateConf', () => { + it('should update survey configuration', async () => { + const surveyId = new ObjectId(); + const surveyMeta = { + _id: surveyId, + surveyType: 'exam', + owner: 'testUser', + } as SurveyMeta; + + jest + .spyOn(surveyMetaService, 'checkSurveyAccess') + .mockResolvedValue(Promise.resolve(surveyMeta)); + jest + .spyOn(surveyConfService, 'saveSurveyConf') + .mockResolvedValue(undefined); + jest + .spyOn(surveyHistoryService, 'addHistory') + .mockResolvedValue(undefined); + + const reqBody = { + surveyId: surveyId.toString(), + configData: { + bannerConf: { + titleConfig: {}, + bannerConfig: {}, + }, + baseConf: { + begTime: '2024-01-23 21:59:05', + endTime: '2034-01-23 21:59:05', + }, + bottomConf: { logoImage: '/imgs/Logo.jpg', logoImageWidth: '60%' }, + skinConf: { skinColor: '#4a4c5b', inputBgColor: '#ffffff' }, + submitConf: {}, + dataConf: { + dataList: [], + }, + }, + }; + + const result = await controller.updateConf(reqBody, { + user: { username: 'testUser', _id: 'testUserId' }, + }); + + expect(result).toEqual({ + code: 200, + }); + }); + + // Add more test cases for different scenarios + }); + + describe('deleteSurvey', () => { + it('should delete a survey and its related data', async () => { + const surveyId = new ObjectId(); + const surveyMeta = { + _id: surveyId, + surveyType: 'exam', + owner: 'testUser', + } as SurveyMeta; + + jest + .spyOn(surveyMetaService, 'checkSurveyAccess') + .mockResolvedValue(Promise.resolve(surveyMeta)); + jest + .spyOn(surveyMetaService, 'deleteSurveyMeta') + .mockResolvedValue(undefined); + jest + .spyOn(responseSchemaService, 'deleteResponseSchema') + .mockResolvedValue(undefined); + + const result = await controller.deleteSurvey( + { surveyId: surveyId.toString() }, + { user: { username: 'testUser' } }, + ); + + expect(result).toEqual({ + code: 200, + }); + }); + + // Add more test cases for different scenarios + }); + + describe('getSurvey', () => { + it('should return survey metadata and configuration', async () => { + const surveyId = new ObjectId(); + const surveyMeta = { + _id: surveyId, + surveyType: 'exam', + owner: 'testUser', + } as SurveyMeta; + + jest + .spyOn(surveyMetaService, 'checkSurveyAccess') + .mockResolvedValue(Promise.resolve(surveyMeta)); + + jest + .spyOn(surveyConfService, 'getSurveyConfBySurveyId') + .mockResolvedValue( + Promise.resolve({ + _id: new ObjectId(), + pageId: surveyId.toString(), + } as SurveyConf), + ); + + const request = { user: { username: 'testUser' } }; + const result = await controller.getSurvey( + { surveyId: surveyId.toString() }, + request, + ); + expect(result?.data?.surveyMetaRes).toBeDefined(); + expect(result?.data?.surveyConfRes).toBeDefined(); + }); + }); + + describe('publishSurvey', () => { + it('should publish a survey and its response schema', async () => { + const surveyId = new ObjectId(); + const surveyMeta = { + _id: surveyId, + surveyType: 'exam', + owner: 'testUser', + } as SurveyMeta; + + jest + .spyOn(surveyMetaService, 'checkSurveyAccess') + .mockResolvedValue(Promise.resolve(surveyMeta)); + + jest + .spyOn(surveyConfService, 'getSurveyConfBySurveyId') + .mockResolvedValue( + Promise.resolve({ + _id: new ObjectId(), + pageId: surveyId.toString(), + } as SurveyConf), + ); + + jest + .spyOn(surveyConfService, 'getSurveyContentByCode') + .mockResolvedValue({ + text: '题目1', + }); + + jest + .spyOn(contentSecurityService, 'isForbiddenContent') + .mockResolvedValue(false); + jest + .spyOn(surveyMetaService, 'publishSurveyMeta') + .mockResolvedValue(undefined); + jest + .spyOn(responseSchemaService, 'publishResponseSchema') + .mockResolvedValue(undefined); + jest + .spyOn(surveyHistoryService, 'addHistory') + .mockResolvedValue(undefined); + + const result = await controller.publishSurvey( + { surveyId: surveyId.toString() }, + { user: { username: 'testUser', _id: 'testUserId' } }, + ); + + expect(result).toEqual({ + code: 200, + }); + }); + }); +}); diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts new file mode 100644 index 00000000..b8c3f023 --- /dev/null +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; + +import { DataStatisticService } from '../services/dataStatistic.service'; +import { SurveyMetaService } from '../services/surveyMeta.service'; +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; + +import * as Joi from 'joi'; +import { Authtication } from 'src/guards/authtication'; +import { XiaojuSurveyPluginManager } from 'src/plugins/pluginManager'; + +@Controller('/api/survey/dataStatistic') +export class DataStatisticController { + constructor( + private readonly surveyMetaService: SurveyMetaService, + private readonly responseSchemaService: ResponseSchemaService, + private readonly dataStatisticService: DataStatisticService, + private readonly pluginManager: XiaojuSurveyPluginManager, + ) {} + + @UseGuards(Authtication) + @Get('/dataTable') + @HttpCode(200) + async data( + @Query() + queryInfo, + @Request() + req, + ) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + isDesensitive: Joi.boolean().default(true), // 默认true就是需要脱敏 + page: Joi.number().default(1), + pageSize: Joi.number().default(10), + }).validateAsync(queryInfo); + const { surveyId, isDesensitive, page, pageSize } = validationResult; + const username = req.user.username; + await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const { total, listHead, listBody } = + await this.dataStatisticService.getDataTable({ + responseSchema, + surveyId, + pageNum: page, + pageSize, + }); + + if (isDesensitive) { + // 脱敏 + listBody.forEach((item) => { + this.pluginManager.triggerHook('desensitiveData', item); + }); + } + + return { + code: 200, + data: { + total, + listHead, + listBody, + }, + }; + } +} diff --git a/server/src/modules/survey/controllers/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts new file mode 100644 index 00000000..1a9efdfc --- /dev/null +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -0,0 +1,256 @@ +import { + Controller, + Post, + Get, + Body, + Query, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; + +import { SurveyMetaService } from '../services/surveyMeta.service'; +import { SurveyConfService } from '../services/surveyConf.service'; +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; +import { ContentSecurityService } from '../services/contentSecurity.service'; +import { SurveyHistoryService } from '../services/surveyHistory.service'; + +import BannerData from '../template/banner/index.json'; +import * as Joi from 'joi'; +import { Authtication } from 'src/guards/authtication'; +import { HISTORY_TYPE } from 'src/enums'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +@Controller('/api/survey') +export class SurveyController { + constructor( + private readonly surveyMetaService: SurveyMetaService, + private readonly surveyConfService: SurveyConfService, + private readonly responseSchemaService: ResponseSchemaService, + private readonly contentSecurityService: ContentSecurityService, + private readonly surveyHistoryService: SurveyHistoryService, + ) {} + + @Get('/getBannerData') + @HttpCode(200) + async getBannerData() { + return { + code: 200, + data: BannerData, + }; + } + + @UseGuards(Authtication) + @Post('/createSurvey') + @HttpCode(200) + async createSurvey( + @Body() + reqBody, + @Request() + req, + ) { + const validationResult = await Joi.object({ + remark: Joi.string().required(), + title: Joi.string().required(), + surveyType: Joi.string().when('createMethod', { + is: 'copy', + then: Joi.allow(null), + otherwise: Joi.required(), + }), + createMethod: Joi.string().allow(null).default('basic'), + createFrom: Joi.string().when('createMethod', { + is: 'copy', + then: Joi.required(), + otherwise: Joi.allow(null), + }), + }).validateAsync(reqBody); + + const { title, remark, createMethod, createFrom } = validationResult; + + const username = req.user.username; + let surveyType = ''; + if (createMethod === 'copy') { + const survey = await this.surveyMetaService.checkSurveyAccess({ + surveyId: createFrom, + username, + }); + surveyType = survey.surveyType; + } else { + surveyType = validationResult.surveyType; + } + + const surveyMeta = await this.surveyMetaService.createSurveyMeta({ + title, + remark, + surveyType, + username, + createMethod, + createFrom, + }); + await this.surveyConfService.createSurveyConf({ + surveyId: surveyMeta._id.toString(), + surveyType: surveyType, + createMethod: validationResult.createMethod, + createFrom: validationResult.createFrom, + }); + return { + code: 200, + data: { + id: surveyMeta._id.toString(), + }, + }; + } + + @UseGuards(Authtication) + @Post('/updateConf') + @HttpCode(200) + async updateConf( + @Body() + surveyInfo, + @Request() + req, + ) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + configData: Joi.any().required(), + }).validateAsync(surveyInfo); + const username = req.user.username; + const surveyId = validationResult.surveyId; + await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + const configData = validationResult.configData; + await this.surveyConfService.saveSurveyConf({ + surveyId, + schema: configData, + }); + await this.surveyHistoryService.addHistory({ + surveyId, + schema: configData, + type: HISTORY_TYPE.DAILY_HIS, + user: { + _id: req.user._id.toString(), + username, + }, + }); + return { + code: 200, + }; + } + + @UseGuards(Authtication) + @HttpCode(200) + @Post('/deleteSurvey') + async deleteSurvey(@Body() reqBody, @Request() req) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + }).validateAsync(reqBody, { allowUnknown: true }); + const username = req.user.username; + const surveyId = validationResult.surveyId; + const survey = await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + + await this.surveyMetaService.deleteSurveyMeta(survey); + await this.responseSchemaService.deleteResponseSchema({ + surveyPath: survey.surveyPath, + }); + + return { + code: 200, + }; + } + + @UseGuards(Authtication) + @Get('/getSurvey') + @HttpCode(200) + async getSurvey( + @Query() + queryInfo: { + surveyId: string; + }, + @Request() + req, + ) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + }).validateAsync(queryInfo); + + const username = req.user.username; + const surveyId = validationResult.surveyId; + const surveyMeta = await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + const surveyConf = + await this.surveyConfService.getSurveyConfBySurveyId(surveyId); + + return { + code: 200, + data: { + surveyMetaRes: surveyMeta, + surveyConfRes: surveyConf, + }, + }; + } + + @UseGuards(Authtication) + @Post('/publishSurvey') + @HttpCode(200) + async publishSurvey( + @Body() + surveyInfo, + @Request() + req, + ) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + }).validateAsync(surveyInfo); + const username = req.user.username; + const surveyId = validationResult.surveyId; + const surveyMeta = await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + const surveyConf = + await this.surveyConfService.getSurveyConfBySurveyId(surveyId); + + const { text } = await this.surveyConfService.getSurveyContentByCode( + surveyConf.code, + ); + + if (await this.contentSecurityService.isForbiddenContent({ text })) { + throw new HttpException( + '问卷存在非法关键字,不允许发布', + EXCEPTION_CODE.SURVEY_CONTENT_NOT_ALLOW, + ); + } + + await this.surveyMetaService.publishSurveyMeta({ + surveyMeta, + }); + + await this.responseSchemaService.publishResponseSchema({ + title: surveyMeta.title, + surveyPath: surveyMeta.surveyPath, + code: surveyConf.code, + pageId: surveyId, + }); + + await this.surveyHistoryService.addHistory({ + surveyId, + schema: surveyConf.code, + type: HISTORY_TYPE.PUBLISH_HIS, + user: { + _id: req.user._id.toString(), + username, + }, + }); + return { + code: 200, + }; + } +} diff --git a/server/src/modules/survey/controllers/surveyHistory.controller.ts b/server/src/modules/survey/controllers/surveyHistory.controller.ts new file mode 100644 index 00000000..d977e69b --- /dev/null +++ b/server/src/modules/survey/controllers/surveyHistory.controller.ts @@ -0,0 +1,56 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; + +import { SurveyHistoryService } from '../services/surveyHistory.service'; +import { SurveyMetaService } from '../services/surveyMeta.service'; + +import * as Joi from 'joi'; +import { Authtication } from 'src/guards/authtication'; + +@Controller('/api/surveyHisotry') +export class SurveyHistoryController { + constructor( + private readonly surveyHistoryService: SurveyHistoryService, + private readonly surveyMetaService: SurveyMetaService, + ) {} + + @UseGuards(Authtication) + @Get('/getList') + @HttpCode(200) + async getList( + @Query() + queryInfo: { + surveyId: string; + historyType: string; + }, + @Request() + req, + ) { + const validationResult = await Joi.object({ + surveyId: Joi.string().required(), + historyType: Joi.string().required(), + }).validateAsync(queryInfo); + + const username = req.user.username; + const surveyId = validationResult.surveyId; + const historyType = validationResult.historyType; + await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + const data = await this.surveyHistoryService.getHistoryList({ + surveyId, + historyType, + }); + return { + code: 200, + data, + }; + } +} diff --git a/server/src/modules/survey/controllers/surveyMeta.controller.ts b/server/src/modules/survey/controllers/surveyMeta.controller.ts new file mode 100644 index 00000000..f1bdd6de --- /dev/null +++ b/server/src/modules/survey/controllers/surveyMeta.controller.ts @@ -0,0 +1,185 @@ +import { + Controller, + Post, + Get, + Body, + Query, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; + +import { SurveyMetaService } from '../services/surveyMeta.service'; + +import * as Joi from 'joi'; +import { Authtication } from 'src/guards/authtication'; +import moment from 'moment'; + +type FilterItem = { + comparator?: string; + condition: Array; +}; + +type FilterCondition = { + field: string; + comparator?: string; + value: string & Array; +}; + +type OrderItem = { + field: string; + value: number; +}; + +@Controller('/api/survey') +export class SurveyMetaController { + constructor(private readonly surveyMetaService: SurveyMetaService) {} + + @UseGuards(Authtication) + @Post('/updateMeta') + @HttpCode(200) + async updateMeta(@Body() reqBody, @Request() req) { + const validationResult = await Joi.object({ + remark: Joi.string().allow(null).default(''), + title: Joi.string().required(), + surveyId: Joi.string().required(), + }).validateAsync(reqBody, { allowUnknown: true }); + const username = req.user.username; + const surveyId = validationResult.surveyId; + const survey = await this.surveyMetaService.checkSurveyAccess({ + surveyId, + username, + }); + survey.title = validationResult.title; + survey.remark = validationResult.remark; + + await this.surveyMetaService.editSurveyMeta(survey); + + return { + code: 200, + }; + } + + @UseGuards(Authtication) + @Get('/getList') + @HttpCode(200) + async getList( + @Query() + queryInfo: { + curPage: number; + pageSize: number; + }, + @Request() + req, + ) { + const validationResult = await Joi.object({ + curPage: Joi.number().required(), + pageSize: Joi.number().allow(null).default(10), + filter: Joi.string().allow(null), + order: Joi.string().allow(null), + }).validateAsync(queryInfo); + const { curPage, pageSize } = validationResult; + let filter = {}, + order = {}; + if (validationResult.filter) { + try { + filter = this.getFilter( + JSON.parse(decodeURIComponent(validationResult.filter)), + ); + } catch (error) { + console.log(error); + } + } + if (validationResult.order) { + try { + order = order = this.getOrder( + JSON.parse(decodeURIComponent(validationResult.order)), + ); + } catch (error) { + console.log(error); + } + } + const username = req.user.username; + const data = await this.surveyMetaService.getSurveyMetaList({ + pageNum: curPage, + pageSize: pageSize, + username, + filter, + order, + }); + return { + code: 200, + data: { + count: data.count, + data: data.data.map((item) => { + const fmt = 'YYYY-MM-DD HH:mm:ss'; + if (!item.surveyType) { + item.surveyType = item.questionType || 'normal'; + } + item.createDate = moment(item.createDate).format(fmt); + item.updateDate = moment(item.updateDate).format(fmt); + item.curStatus.date = moment(item.curStatus.date).format(fmt); + return item; + }), + }, + }; + } + + private getFilter(filterList: Array) { + const allowFilterField = [ + 'title', + 'remark', + 'questionType', + 'curStatus.status', + ]; + return filterList.reduce( + (preItem, curItem) => { + const condition = curItem.condition + .filter((item) => allowFilterField.includes(item.field)) + .reduce((pre, cur) => { + switch (cur.comparator) { + case '$ne': + pre[cur.field] = { + $ne: cur.value, + }; + break; + case '$regex': + pre[cur.field] = { + $regex: cur.value, + }; + break; + default: + pre[cur.field] = cur.value; + break; + } + return pre; + }, {}); + switch (curItem.comparator) { + case '$or': + if (!Array.isArray(preItem.$or)) { + preItem.$or = []; + } + preItem.$or.push(condition); + break; + default: + Object.assign(preItem, condition); + break; + } + return preItem; + }, + {} as { $or?: Array> } & Record, + ); + } + + private getOrder(order: Array) { + const allowOrderFields = ['createDate', 'updateDate', 'curStatus.date']; + + const orderList = order.filter((orderItem) => + allowOrderFields.includes(orderItem.field), + ); + return orderList.reduce((pre, cur) => { + pre[cur.field] = cur.value === 1 ? 1 : -1; + return pre; + }, {}); + } +} diff --git a/server/src/modules/survey/controllers/surveyUI.controller.ts b/server/src/modules/survey/controllers/surveyUI.controller.ts new file mode 100644 index 00000000..17b0c863 --- /dev/null +++ b/server/src/modules/survey/controllers/surveyUI.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { join } from 'path'; + +@Controller() +export class SurveyUIController { + constructor() {} + + @Get('/') + home(@Res() res: Response) { + res.sendFile(join(__dirname, 'src/../', 'public', 'management.html')); + } + + @Get('/management/:surveyId') + management(@Param('surveyId') surveyId: string, @Res() res: Response) { + res.sendFile(join(__dirname, 'src/../', 'public', 'management.html')); + } +} diff --git a/server/src/modules/survey/services/contentSecurity.service.ts b/server/src/modules/survey/services/contentSecurity.service.ts new file mode 100644 index 00000000..ab533bb4 --- /dev/null +++ b/server/src/modules/survey/services/contentSecurity.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { Word } from 'src/models/word.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; + +@Injectable() +export class ContentSecurityService { + private forbiddenWords = null; + constructor( + @InjectRepository(Word) + private readonly wordRepository: MongoRepository, + ) {} + + private async initForbiddenWords() { + const words = await this.wordRepository.find({ + where: { + type: 'forbidden', + }, + }); + this.forbiddenWords = words.map((item) => item.text); + } + + async isForbiddenContent({ text }: { text: string }) { + if (!this.forbiddenWords) { + await this.initForbiddenWords(); + } + for (const word of this.forbiddenWords) { + if (text.includes(word)) { + return true; + } + } + return false; + } +} diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts new file mode 100644 index 00000000..a8880828 --- /dev/null +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; + +import moment from 'moment'; +import { keyBy } from 'lodash'; +import { DataItem } from 'src/interfaces/survey'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; + +@Injectable() +export class DataStatisticService { + constructor( + @InjectRepository(SurveyResponse) + private readonly surveyResponseRepository: MongoRepository, + ) {} + + private getListHeadByDataList(dataList) { + const listHead = dataList.map((question) => { + let othersCode; + if (question.type === 'radio-star') { + const rangeConfigKeys = Object.keys(question.rangeConfig); + if (rangeConfigKeys.length > 0) { + othersCode = [ + { code: `${question.field}_custom`, option: '填写理由' }, + ]; + } + } else { + othersCode = (question.options || []) + .filter((optionItem) => optionItem.othersKey) + .map((optionItem) => { + return { + code: optionItem.othersKey, + option: optionItem.text, + }; + }); + } + return { + field: question.field, + title: question.title, + type: question.type, + othersCode, + }; + }); + listHead.push({ + field: 'difTime', + title: '答题耗时(秒)', + type: 'text', + }); + listHead.push({ + field: 'createDate', + title: '提交时间', + type: 'text', + }); + return listHead; + } + + async getDataTable({ + surveyId, + pageNum, + pageSize, + responseSchema, + }: { + surveyId: string; + pageNum: number; + pageSize: number; + responseSchema: ResponseSchema; + }) { + const dataList = responseSchema?.code?.dataConf?.dataList || []; + const listHead = this.getListHeadByDataList(dataList); + const dataListMap = keyBy(dataList, 'field'); + const where = { + pageId: surveyId, + 'curStatus.status': { + $ne: 'removed', + }, + }; + const [surveyResponseList, total] = + await this.surveyResponseRepository.findAndCount({ + where, + take: pageSize, + skip: (pageNum - 1) * pageSize, + order: { + createDate: -1, + }, + }); + + const listBody = surveyResponseList.map((submitedData) => { + const data = submitedData.data; + const dataKeys = Object.keys(data); + + for (const itemKey of dataKeys) { + if (typeof itemKey !== 'string') { + continue; + } + if (itemKey.indexOf('data') !== 0) { + continue; + } + // 获取题目id + const itemConfigKey = itemKey.split('_')[0]; + // 获取题目 + const itemConfig: DataItem = dataListMap[itemConfigKey]; + // 题目删除会出现,数据列表报错 + if (!itemConfig) { + continue; + } + // 处理选项的更多输入框 + if ( + itemConfig.type === 'radio-star' && + !data[`${itemConfigKey}_custom`] + ) { + data[`${itemConfigKey}_custom`] = + data[`${itemConfigKey}_${data[itemConfigKey]}`]; + } + // 将选项id还原成选项文案 + if ( + Array.isArray(itemConfig.options) && + itemConfig.options.length > 0 + ) { + const optionTextMap = keyBy(itemConfig.options, 'hash'); + data[itemKey] = Array.isArray(data[itemKey]) + ? data[itemKey] + .map((item) => optionTextMap[item]?.text || item) + .join(',') + : optionTextMap[data[itemKey]]?.text || data[itemKey]; + } + } + return { + ...data, + difTime: (submitedData.difTime / 1000).toFixed(2), + createDate: moment(submitedData.createDate).format( + 'YYYY-MM-DD HH:mm:ss', + ), + }; + }); + return { + total, + listHead, + listBody, + }; + } +} diff --git a/server/src/modules/survey/services/surveyConf.service.ts b/server/src/modules/survey/services/surveyConf.service.ts new file mode 100644 index 00000000..b7c4e8c7 --- /dev/null +++ b/server/src/modules/survey/services/surveyConf.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { SurveyConf } from 'src/models/surveyConf.entity'; +import templateBase from '../template/surveyTemplate/templateBase.json'; +import normalCode from '../template/surveyTemplate/survey/normal.json'; +import npsCode from '../template/surveyTemplate/survey/nps.json'; +import registerCode from '../template/surveyTemplate/survey/register.json'; +import voteCode from '../template/surveyTemplate/survey/vote.json'; +import { get } from 'lodash'; +import moment from 'moment'; + +import { HttpException } from 'src/exceptions/httpException'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { SurveySchemaInterface } from 'src/interfaces/survey'; + +const schemaDataMap = { + normal: normalCode, + nps: npsCode, + register: registerCode, + vote: voteCode, +}; + +@Injectable() +export class SurveyConfService { + constructor( + @InjectRepository(SurveyConf) + private readonly surveyConfRepository: MongoRepository, + ) {} + + private async getSchemaBySurveyType(surveyType: string) { + // Implement your logic here + const codeData = get(schemaDataMap, surveyType); + if (!codeData) { + throw new HttpException( + '问卷类型不存在', + EXCEPTION_CODE.SURVEY_TYPE_ERROR, + ); + } + const code = Object.assign({}, templateBase, codeData); + const nowMoment = moment(); + code.baseConf.begTime = nowMoment.format('YYYY-MM-DD HH:mm:ss'); + code.baseConf.endTime = nowMoment + .add(10, 'years') + .format('YYYY-MM-DD HH:mm:ss'); + return code; + } + + async createSurveyConf(params: { + surveyId: string; + surveyType: string; + createMethod: string; + createFrom: string; + }) { + const { surveyId, surveyType, createMethod, createFrom } = params; + let schemaData = null; + if (createMethod === 'copy') { + const codeInfo = await this.getSurveyConfBySurveyId(createFrom); + schemaData = codeInfo.code; + } else { + schemaData = await this.getSchemaBySurveyType(surveyType); + } + + const newCode = this.surveyConfRepository.create({ + pageId: surveyId, + code: schemaData, + }); + + return this.surveyConfRepository.save(newCode); + } + + async getSurveyConfBySurveyId(surveyId: string) { + const code = await this.surveyConfRepository.findOne({ + where: { pageId: surveyId }, + }); + if (!code) { + throw new SurveyNotFoundException('问卷配置不存在'); + } + return code; + } + + async saveSurveyConf(params: { + surveyId: string; + schema: SurveySchemaInterface; + }) { + const codeInfo = await this.getSurveyConfBySurveyId(params.surveyId); + if (!codeInfo) { + throw new SurveyNotFoundException('问卷配置不存在'); + } + codeInfo.code = params.schema; + await this.surveyConfRepository.save(codeInfo); + } + + async getSurveyContentByCode(codeInfo: SurveySchemaInterface) { + const dataList = codeInfo.dataConf.dataList; + const arr: Array = []; + for (const item of dataList) { + arr.push(item.title); + if (Array.isArray(item.options)) { + for (const option of item.options) { + arr.push(option.text); + } + } + } + return { + text: arr.join('\n'), + }; + } +} diff --git a/server/src/modules/survey/services/surveyHistory.service.ts b/server/src/modules/survey/services/surveyHistory.service.ts new file mode 100644 index 00000000..4a40fd47 --- /dev/null +++ b/server/src/modules/survey/services/surveyHistory.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { SurveyHistory } from 'src/models/surveyHistory.entity'; +import { HISTORY_TYPE } from 'src/enums'; +import { SurveySchemaInterface } from 'src/interfaces/survey'; + +@Injectable() +export class SurveyHistoryService { + constructor( + @InjectRepository(SurveyHistory) + private readonly surveyHistory: MongoRepository, + ) {} + + async addHistory(params: { + surveyId: string; + schema: SurveySchemaInterface; + type: HISTORY_TYPE; + user: any; + }) { + const { surveyId, schema, type, user } = params; + const newHistory = this.surveyHistory.create({ + pageId: surveyId, + type, + schema, + operator: { + _id: user._id.toString(), + username: user.username, + }, + }); + return this.surveyHistory.save(newHistory); + } + + async getHistoryList({ + surveyId, + historyType, + }: { + surveyId: string; + historyType: HISTORY_TYPE; + }) { + return this.surveyHistory.find({ + where: { + pageId: surveyId, + type: historyType, + }, + take: 100, + }); + } +} diff --git a/server/src/modules/survey/services/surveyMeta.service.ts b/server/src/modules/survey/services/surveyMeta.service.ts new file mode 100644 index 00000000..2ea041dd --- /dev/null +++ b/server/src/modules/survey/services/surveyMeta.service.ts @@ -0,0 +1,163 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository, FindOptionsOrder } from 'typeorm'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { RECORD_STATUS } from 'src/enums'; +import { ObjectId } from 'mongodb'; +import { NoSurveyPermissionException } from 'src/exceptions/noSurveyPermissionException'; +import { HttpException } from 'src/exceptions/httpException'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { XiaojuSurveyPluginManager } from 'src/plugins/pluginManager'; + +@Injectable() +export class SurveyMetaService { + constructor( + @InjectRepository(SurveyMeta) + private readonly surveyRepository: MongoRepository, + private readonly pluginManager: XiaojuSurveyPluginManager, + ) {} + + private async getNewSurveyPath(): Promise { + let surveyPath = this.pluginManager.triggerHook('genSurveyPath'); + while (true) { + const count = await this.surveyRepository.count({ + where: { + surveyPath, + }, + }); + if (count === 0) { + break; + } + surveyPath = this.pluginManager.triggerHook('genSurveyPath'); + } + return surveyPath; + } + + async checkSurveyAccess({ surveyId, username }) { + const survey = await this.surveyRepository.findOne({ + where: { _id: new ObjectId(surveyId) }, + }); + + if (!survey) { + throw new SurveyNotFoundException('问卷不存在'); + } + if (survey.owner !== username) { + throw new NoSurveyPermissionException('没有权限'); + } + return survey; + } + + async createSurveyMeta(params: { + title: string; + remark: string; + surveyType: string; + username: string; + createMethod: string; + createFrom: string; + }) { + const { title, remark, surveyType, username, createMethod, createFrom } = + params; + const surveyPath = await this.getNewSurveyPath(); + const newSurvey = this.surveyRepository.create({ + title, + remark: remark || '', + surveyType: surveyType, + surveyPath, + creator: username, + owner: username, + createMethod, + createFrom, + }); + + return await this.surveyRepository.save(newSurvey); + } + + async editSurveyMeta(survey: SurveyMeta) { + if ( + survey.curStatus.status !== RECORD_STATUS.NEW && + survey.curStatus.status !== RECORD_STATUS.EDITING + ) { + const newStatus = { + status: RECORD_STATUS.EDITING, + date: Date.now(), + }; + survey.curStatus = newStatus; + survey.statusList.push(newStatus); + } + return this.surveyRepository.save(survey); + } + + async deleteSurveyMeta(survey: SurveyMeta) { + if (survey.curStatus.status === RECORD_STATUS.REMOVED) { + throw new HttpException( + '问卷已删除,不能重复删除', + EXCEPTION_CODE.SURVEY_STATUS_TRANSFORM_ERROR, + ); + } + const newStatusInfo = { + status: RECORD_STATUS.REMOVED, + date: Date.now(), + }; + survey.curStatus = newStatusInfo; + if (Array.isArray(survey.statusList)) { + survey.statusList.push(newStatusInfo); + } else { + survey.statusList = [newStatusInfo]; + } + return this.surveyRepository.save(survey); + } + + async getSurveyMetaList(condition: { + pageNum: number; + pageSize: number; + username: string; + filter: Record; + order: Record; + }): Promise<{ data: any[]; count: number }> { + const { pageNum, pageSize, username } = condition; + const skip = (pageNum - 1) * pageSize; + try { + const query = Object.assign( + {}, + { + owner: username, + 'curStatus.status': { + $ne: 'removed', + }, + }, + condition.filter, + ); + const order = + condition.order && Object.keys(condition.order).length > 0 + ? (condition.order as FindOptionsOrder) + : ({ + createDate: -1, + } as FindOptionsOrder); + + const [data, count] = await this.surveyRepository.findAndCount({ + where: query, + skip, + take: pageSize, + order, + }); + return { data, count }; + } catch (error) { + return { data: [], count: 0 }; + } + } + + async publishSurveyMeta({ surveyMeta }: { surveyMeta: SurveyMeta }) { + const curStatus = { + status: RECORD_STATUS.PUBLISHED, + date: Date.now(), + }; + surveyMeta.curStatus = curStatus; + if (Array.isArray(surveyMeta.statusList)) { + surveyMeta.statusList.push(curStatus); + } else { + surveyMeta.statusList = [curStatus]; + } + return this.surveyRepository.save(surveyMeta); + } +} diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts new file mode 100644 index 00000000..d2ac9ba0 --- /dev/null +++ b/server/src/modules/survey/survey.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; + +import { ConfigModule } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SurveyResponseModule } from '../surveyResponse/surveyResponse.module'; +import { AuthModule } from '../auth/auth.module'; + +import { DataStatisticController } from './controllers/dataStatistic.controller'; +import { SurveyController } from './controllers/survey.controller'; +import { SurveyHistoryController } from './controllers/surveyHistory.controller'; +import { SurveyMetaController } from './controllers/surveyMeta.controller'; +import { SurveyUIController } from './controllers/surveyUI.controller'; + +import { SurveyConf } from 'src/models/surveyConf.entity'; +import { SurveyHistory } from 'src/models/surveyHistory.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { Word } from 'src/models/word.entity'; + +import { DataStatisticService } from './services/dataStatistic.service'; +import { SurveyConfService } from './services/surveyConf.service'; +import { SurveyHistoryService } from './services/surveyHistory.service'; +import { SurveyMetaService } from './services/surveyMeta.service'; +import { ContentSecurityService } from './services/contentSecurity.service'; + +import { PluginManagerProvider } from 'src/plugins/pluginManager.provider'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SurveyMeta, + SurveyConf, + SurveyHistory, + SurveyResponse, + Word, + ]), + ConfigModule, + SurveyResponseModule, + AuthModule, + ], + controllers: [ + DataStatisticController, + SurveyController, + SurveyHistoryController, + SurveyMetaController, + SurveyUIController, + ], + providers: [ + DataStatisticService, + SurveyHistoryService, + SurveyConfService, + SurveyMetaService, + PluginManagerProvider, + ContentSecurityService, + ], +}) +export class SurveyModule {} diff --git a/server/src/apps/surveyManage/template/banner/index.json b/server/src/modules/survey/template/banner/index.json similarity index 100% rename from server/src/apps/surveyManage/template/banner/index.json rename to server/src/modules/survey/template/banner/index.json diff --git a/server/src/apps/surveyManage/template/surveyTemplate/survey/normal.json b/server/src/modules/survey/template/surveyTemplate/survey/normal.json similarity index 91% rename from server/src/apps/surveyManage/template/surveyTemplate/survey/normal.json rename to server/src/modules/survey/template/surveyTemplate/survey/normal.json index f1d25af3..7794b7af 100644 --- a/server/src/apps/surveyManage/template/surveyTemplate/survey/normal.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/normal.json @@ -5,7 +5,7 @@ "subTitle": "" }, "bannerConfig": { - "bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg", + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg", "videoLink": "", "postImg": "" } @@ -26,12 +26,7 @@ "checked": false, "minNum": "", "maxNum": "", - "maxPhotos": 5, "star": 5, - "timeStep": { - "hour": 1, - "min": 10 - }, "nps": { "leftText": "极不满意", "rightText": "极满意" @@ -89,11 +84,6 @@ "importData": "", "cOption": "", "cOptions": [], - "timeStep": { - "hour": 1, - "min": 10 - }, - "maxPhotos": 5, "nps": { "leftText": "极不满意", "rightText": "极满意" diff --git a/server/src/apps/surveyManage/template/surveyTemplate/survey/nps.json b/server/src/modules/survey/template/surveyTemplate/survey/nps.json similarity index 97% rename from server/src/apps/surveyManage/template/surveyTemplate/survey/nps.json rename to server/src/modules/survey/template/surveyTemplate/survey/nps.json index 1321bdf4..71e1fe19 100644 --- a/server/src/apps/surveyManage/template/surveyTemplate/survey/nps.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/nps.json @@ -23,7 +23,7 @@ "subTitle": "" }, "bannerConfig": { - "bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg", + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg", "videoLink": "", "postImg": "" } diff --git a/server/src/apps/surveyManage/template/surveyTemplate/survey/register.json b/server/src/modules/survey/template/surveyTemplate/survey/register.json similarity index 93% rename from server/src/apps/surveyManage/template/surveyTemplate/survey/register.json rename to server/src/modules/survey/template/surveyTemplate/survey/register.json index 9566d53e..5dfcf17b 100644 --- a/server/src/apps/surveyManage/template/surveyTemplate/survey/register.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/register.json @@ -5,7 +5,7 @@ "subTitle": "" }, "bannerConfig": { - "bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg", + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg", "videoLink": "", "postImg": "" } @@ -26,12 +26,7 @@ "checked": false, "minNum": "", "maxNum": "", - "maxPhotos": 5, "star": 5, - "timeStep": { - "hour": 1, - "min": 10 - }, "nps": { "leftText": "极不满意", "rightText": "极满意" @@ -112,11 +107,6 @@ "placeholderDesc": "" } ], - "timeStep": { - "hour": 1, - "min": 10 - }, - "maxPhotos": 5, "nps": { "leftText": "极不满意", "rightText": "极满意" diff --git a/server/src/apps/surveyManage/template/surveyTemplate/survey/vote.json b/server/src/modules/survey/template/surveyTemplate/survey/vote.json similarity index 92% rename from server/src/apps/surveyManage/template/surveyTemplate/survey/vote.json rename to server/src/modules/survey/template/surveyTemplate/survey/vote.json index e2a1a414..50ab9ee2 100644 --- a/server/src/apps/surveyManage/template/surveyTemplate/survey/vote.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/vote.json @@ -5,7 +5,7 @@ "subTitle": "" }, "bannerConfig": { - "bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg", + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg", "videoLink": "", "postImg": "" } @@ -45,12 +45,7 @@ "placeholderDesc": "" } ], - "maxPhotos": 5, "star": 5, - "timeStep": { - "hour": 1, - "min": 10 - }, "nps": { "leftText": "极不满意", "rightText": "极满意" @@ -110,11 +105,6 @@ "hash": "115020" } ], - "timeStep": { - "hour": 1, - "min": 10 - }, - "maxPhotos": 5, "nps": { "leftText": "极不满意", "rightText": "极满意" diff --git a/server/src/apps/surveyManage/template/surveyTemplate/templateBase.json b/server/src/modules/survey/template/surveyTemplate/templateBase.json similarity index 85% rename from server/src/apps/surveyManage/template/surveyTemplate/templateBase.json rename to server/src/modules/survey/template/surveyTemplate/templateBase.json index 16489904..a3aa6dfe 100644 --- a/server/src/apps/surveyManage/template/surveyTemplate/templateBase.json +++ b/server/src/modules/survey/template/surveyTemplate/templateBase.json @@ -5,7 +5,7 @@ "subTitle": "" }, "bannerConfig": { - "bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg", + "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg", "videoLink": "", "postImg": "" } @@ -25,7 +25,7 @@ } }, "bottomConf": { - "logoImage": "https://img-hxy021.didistatic.com/static/starimg/img/4XcFlkzll41700742941814.png", + "logoImage": "/imgs/Logo.jpg", "logoImageWidth": "60%" }, "baseConf": { diff --git a/server/src/modules/surveyResponse/__test/clientEncrypt.controller.spec.ts b/server/src/modules/surveyResponse/__test/clientEncrypt.controller.spec.ts new file mode 100644 index 00000000..77b2e730 --- /dev/null +++ b/server/src/modules/surveyResponse/__test/clientEncrypt.controller.spec.ts @@ -0,0 +1,62 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ClientEncryptController } from '../controllers/clientEncrpt.controller'; +import { ClientEncryptService } from '../services/clientEncrypt.service'; +import { ConfigService } from '@nestjs/config'; +import { ENCRYPT_TYPE } from 'src/enums/encrypt'; +import { ObjectId } from 'mongodb'; +import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; + +jest.mock('../services/clientEncrypt.service'); +jest.mock('@nestjs/config'); + +describe('ClientEncryptController', () => { + let controller: ClientEncryptController; + let clientEncryptService: ClientEncryptService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [], + controllers: [ClientEncryptController], + providers: [ClientEncryptService, ConfigService], + }).compile(); + + controller = module.get(ClientEncryptController); + clientEncryptService = + module.get(ClientEncryptService); + configService = module.get(ConfigService); + }); + + describe('getEncryptInfo', () => { + it('should return RSA encryption info', async () => { + const id = new ObjectId(); + + jest.spyOn(configService, 'get').mockReturnValueOnce(ENCRYPT_TYPE.RSA); + jest.spyOn(controller, 'getRsaInfo').mockResolvedValue({ + publicKey: 'testPublicKey', + privateKey: 'testPrivateKey', + }); + jest.spyOn(clientEncryptService, 'addRsa').mockImplementation(() => { + return Promise.resolve({ + _id: id, + data: { + publicKey: 'testPublicKey', + }, + } as ClientEncrypt); + }); + + const result = await controller.getEncryptInfo(); + + expect(result).toEqual({ + code: 200, + data: { + data: { + secretKey: 'testPublicKey', + sessionId: id.toString(), + }, + encryptType: ENCRYPT_TYPE.RSA, + }, + }); + }); + }); +}); diff --git a/server/src/modules/surveyResponse/__test/counter.controller.spec.ts b/server/src/modules/surveyResponse/__test/counter.controller.spec.ts new file mode 100644 index 00000000..73f7b7ea --- /dev/null +++ b/server/src/modules/surveyResponse/__test/counter.controller.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CounterController } from '../controllers/counter.controller'; +import { CounterService } from '../services/counter.service'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +jest.mock('../services/counter.service'); + +describe('CounterController', () => { + let controller: CounterController; + let counterService: CounterService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CounterController], + providers: [CounterService], + }).compile(); + + controller = module.get(CounterController); + counterService = module.get(CounterService); + }); + + describe('queryOptionCountInfo', () => { + it('should return vote count information when surveyPath and fieldList are provided', async () => { + const mockQueryInfo = { + surveyPath: 'validSurveyPath', + fieldList: 'field1,field2', + }; + const mockVoteData = { + data558: { '330916': 2, '528834': 1, total: 3 }, + }; + + jest.spyOn(counterService, 'getAll').mockResolvedValue(mockVoteData); + + const result = await controller.queryOptionCountInfo(mockQueryInfo); + + expect(result).toEqual({ + code: 200, + data: mockVoteData, + }); + }); + + it('should throw HttpException with PARAMETER_ERROR code when surveyPath is missing', async () => { + const mockQueryInfo = { surveyPath: '', fieldList: 'field1,field2' }; + + await expect( + controller.queryOptionCountInfo(mockQueryInfo), + ).rejects.toThrow( + new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR), + ); + }); + + it('should throw HttpException with PARAMETER_ERROR code when fieldList is missing', async () => { + const mockQueryInfo = { surveyPath: 'validSurveyPath', fieldList: '' }; + + await expect( + controller.queryOptionCountInfo(mockQueryInfo), + ).rejects.toThrow( + new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR), + ); + }); + }); +}); diff --git a/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts new file mode 100644 index 00000000..bb1395e9 --- /dev/null +++ b/server/src/modules/surveyResponse/__test/responseSchema.controller.spec.ts @@ -0,0 +1,70 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ResponseSchemaController } from '../controllers/responseSchema.controller'; +import { ResponseSchemaService } from '../services/responseScheme.service'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { RECORD_STATUS } from 'src/enums'; + +import { ResponseSchema } from 'src/models/responseSchema.entity'; + +jest.mock('../services/responseScheme.service'); + +describe('ResponseSchemaController', () => { + let controller: ResponseSchemaController; + let responseSchemaService: ResponseSchemaService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ResponseSchemaController], + providers: [ResponseSchemaService], + }).compile(); + + controller = module.get(ResponseSchemaController); + responseSchemaService = module.get( + ResponseSchemaService, + ); + }); + + describe('getSchema', () => { + it('should return response schema when surveyPath is provided and valid', async () => { + const mockQueryInfo = { surveyPath: 'validSurveyPath' }; + const mockResponseSchema = { + surveyPath: 'testSurveyPath', + curStatus: { status: RECORD_STATUS.PUBLISHED, date: Date.now() }, + } as ResponseSchema; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue(Promise.resolve(mockResponseSchema)); + + const result = await controller.getSchema(mockQueryInfo); + + expect(result).toEqual({ + code: 200, + data: mockResponseSchema, + }); + }); + + it('should throw HttpException with PARAMETER_ERROR code when surveyPath is missing', async () => { + const mockQueryInfo = { surveyPath: '' }; + + await expect(controller.getSchema(mockQueryInfo)).rejects.toThrow( + new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR), + ); + }); + + it('should throw HttpException with RESPONSE_SCHEMA_REMOVED code when survey is removed', async () => { + const mockQueryInfo = { surveyPath: 'removedSurveyPath' }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue({ + curStatus: { status: RECORD_STATUS.REMOVED }, + } as ResponseSchema); + + await expect(controller.getSchema(mockQueryInfo)).rejects.toThrow( + new HttpException('问卷已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED), + ); + }); + }); +}); diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts new file mode 100644 index 00000000..b4eca972 --- /dev/null +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -0,0 +1,107 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SurveyResponseController } from '../controllers/surveyResponse.controller'; +import { ResponseSchemaService } from '../services/responseScheme.service'; +import { CounterService } from '../services/counter.service'; +import { SurveyResponseService } from '../services/surveyResponse.service'; +import { ClientEncryptService } from '../services/clientEncrypt.service'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; + +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; +import { RECORD_STATUS } from 'src/enums'; + +import * as aes from 'crypto-js/aes'; + +jest.mock('../services/responseScheme.service'); +jest.mock('../services/counter.service'); +jest.mock('../services/surveyResponse.service'); +jest.mock('../services/clientEncrypt.service'); +jest.mock('src/utils/checkSign'); +jest.mock('crypto-js/aes'); + +describe('SurveyResponseController', () => { + let controller: SurveyResponseController; + let responseSchemaService: ResponseSchemaService; + let clientEncryptService: ClientEncryptService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SurveyResponseController], + providers: [ + ResponseSchemaService, + CounterService, + SurveyResponseService, + ClientEncryptService, + ], + }).compile(); + + controller = module.get(SurveyResponseController); + responseSchemaService = module.get( + ResponseSchemaService, + ); + clientEncryptService = + module.get(ClientEncryptService); + }); + + describe('createResponse', () => { + it('should create survey response successfully with valid parameters', async () => { + const mockReqBody = { + surveyPath: '5q1PbCtvPM', + data: '%7B%22data458%22%3A%22111%22%2C%22data515%22%3A%22xhfudsdg%22%7D', + difTime: 5687, + clientTime: 1706103961153, + encryptType: 'aes', + sessionId: '65b11493e8df57de0ff04c98', + sign: 'c7ca1a8217a9ef0f4c4ed58701899603ce446353784a22c35774240f4cf4c5a4.1706103961154', + }; + const mockResponseSchema = { + curStatus: { status: RECORD_STATUS.PUBLISHED, date: Date.now() }, + code: { + dataConf: { + dataList: [], + }, + }, + }; + const mockClientEncryptData = { + data: { + secretKey: 'testSecretKey', + }, + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue(mockResponseSchema as ResponseSchema); + jest + .spyOn(clientEncryptService, 'getEncryptInfoById') + .mockResolvedValue(mockClientEncryptData as ClientEncrypt); + + jest.spyOn(aes, 'decrypt').mockImplementation((data) => data); + + const result = await controller.createResponse(mockReqBody); + + expect(result).toEqual({ + code: 200, + msg: '提交成功', + }); + }); + + it('should throw SurveyNotFoundException when response schema is not found', async () => { + const mockReqBody = { + surveyPath: '5q1PbCtvPM', + data: '%7B%22data458%22%3A%22111%22%2C%22data515%22%3A%22xhfudsdg%22%7D', + encryptType: 'validEncryptType', + sessionId: 'validSessionId', + clientTime: 123456789, + difTime: 0, + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPath') + .mockResolvedValue(null); + + await expect(controller.createResponse(mockReqBody)).rejects.toThrow( + new SurveyNotFoundException('该问卷不存在,无法提交'), + ); + }); + }); +}); diff --git a/server/src/modules/surveyResponse/controllers/clientEncrpt.controller.ts b/server/src/modules/surveyResponse/controllers/clientEncrpt.controller.ts new file mode 100644 index 00000000..1af1c7c4 --- /dev/null +++ b/server/src/modules/surveyResponse/controllers/clientEncrpt.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, HttpCode } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ClientEncryptService } from '../services/clientEncrypt.service'; +import * as forge from 'node-forge'; +import { ENCRYPT_TYPE } from 'src/enums/encrypt'; + +@Controller('/api/clientEncrypt') +export class ClientEncryptController { + constructor( + private readonly clientEncryptService: ClientEncryptService, + private readonly configService: ConfigService, + ) {} + + @Get('/getEncryptInfo') + @HttpCode(200) + async getEncryptInfo() { + const encryptType = this.configService.get( + 'XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE', + ); + let data = {}; + if (encryptType === 'rsa') { + const { publicKey, privateKey } = await this.getRsaInfo(); + + const rsaInfo = await this.clientEncryptService.addRsa({ + publicKey, + privateKey, + }); + data = { + data: { + secretKey: rsaInfo.data.publicKey, + sessionId: rsaInfo._id.toString(), + }, + encryptType: ENCRYPT_TYPE.RSA, + }; + } + return { + code: 200, + data, + }; + } + + getRsaInfo(): Promise<{ publicKey; privateKey }> { + const keyPair = forge.pki.rsa.generateKeyPair({ bits: 2048 }); + const publicKey = forge.pki.publicKeyToPem(keyPair.publicKey); + const privateKey = forge.pki.privateKeyToPem(keyPair.privateKey); + return Promise.resolve({ publicKey, privateKey }); + } +} diff --git a/server/src/modules/surveyResponse/controllers/counter.controller.ts b/server/src/modules/surveyResponse/controllers/counter.controller.ts new file mode 100644 index 00000000..29f4444b --- /dev/null +++ b/server/src/modules/surveyResponse/controllers/counter.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, HttpCode, Query } from '@nestjs/common'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { CounterService } from '../services/counter.service'; + +@Controller('/api/counter') +export class CounterController { + constructor(private readonly counterService: CounterService) {} + + @Get('/queryOptionCountInfo') + @HttpCode(200) + async queryOptionCountInfo( + @Query() + queryInfo: { + surveyPath: string; + fieldList: string; + }, + ) { + if (!queryInfo.surveyPath || !queryInfo.fieldList) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const data = await this.counterService.getAll({ + surveyPath: queryInfo.surveyPath, + type: 'option', + keyList: queryInfo.fieldList.split(','), + }); + return { + code: 200, + data: data, + }; + } +} diff --git a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts new file mode 100644 index 00000000..bd567b9e --- /dev/null +++ b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, HttpCode, Query } from '@nestjs/common'; +import { ResponseSchemaService } from '../services/responseScheme.service'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { RECORD_STATUS } from 'src/enums'; + +@Controller('/api/responseSchema') +export class ResponseSchemaController { + constructor(private readonly responseSchemaService: ResponseSchemaService) {} + + @Get('/getSchema') + @HttpCode(200) + async getSchema( + @Query() + queryInfo: { + surveyPath: string; + }, + ) { + if (!queryInfo.surveyPath) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPath( + queryInfo.surveyPath, + ); + if ( + !responseSchema || + responseSchema.curStatus.status === RECORD_STATUS.REMOVED + ) { + throw new HttpException( + '问卷已删除', + EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED, + ); + } + return { + code: 200, + data: responseSchema, + }; + } +} diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts new file mode 100644 index 00000000..3b9f2858 --- /dev/null +++ b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts @@ -0,0 +1,196 @@ +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { HttpException } from 'src/exceptions/httpException'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { checkSign } from 'src/utils/checkSign'; +import * as Joi from 'joi'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { ResponseSchemaService } from '../services/responseScheme.service'; +import { CounterService } from '../services/counter.service'; +import moment from 'moment'; +import { SurveyResponseService } from '../services/surveyResponse.service'; +import { ClientEncryptService } from '../services/clientEncrypt.service'; +import { ENCRYPT_TYPE } from 'src/enums/encrypt'; +import * as forge from 'node-forge'; + +@Controller('/api/surveyResponse') +export class SurveyResponseController { + constructor( + private readonly responseSchemaService: ResponseSchemaService, + private readonly counterService: CounterService, + private readonly surveyResponseService: SurveyResponseService, + private readonly clientEncryptService: ClientEncryptService, + ) {} + + @Post('/createResponse') + @HttpCode(200) + async createResponse(@Body() reqBody) { + // 检查签名 + checkSign(reqBody); + // 校验参数 + const validationResult = await Joi.object({ + surveyPath: Joi.string().required(), + data: Joi.any().required(), + encryptType: Joi.string(), + sessionId: Joi.string(), + clientTime: Joi.number().required(), + difTime: Joi.number(), + }).validateAsync(reqBody, { allowUnknown: true }); + + const { surveyPath, encryptType, data, sessionId, clientTime, difTime } = + validationResult; + + // 查询schema + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPath(surveyPath); + if (!responseSchema || responseSchema.curStatus.status === 'removed') { + throw new SurveyNotFoundException('该问卷不存在,无法提交'); + } + + const now = Date.now(); + // 提交时间限制 + const begTime = responseSchema.code?.baseConf?.begTime || 0; + const endTime = responseSchema?.code?.baseConf?.endTime || 0; + if (begTime && endTime) { + const begTimeStamp = new Date(begTime).getTime(); + const endTimeStamp = new Date(endTime).getTime(); + if (now < begTimeStamp || now > endTimeStamp) { + throw new HttpException( + '不在答题有效期内', + EXCEPTION_CODE.RESPONSE_CURRENT_TIME_NOT_ALLOW, + ); + } + } + + // 提交时间段限制 + const answerBegTime = + responseSchema?.code?.baseConf?.answerBegTime || '00:00:00'; + const answerEndTime = + responseSchema?.code?.baseConf?.answerEndTime || '00:00:00'; + if (answerBegTime && answerEndTime && answerBegTime !== answerEndTime) { + const ymdString = moment().format('YYYY-MM-DD'); + const answerBegTimeStamp = new Date( + `${ymdString} ${answerBegTime}`, + ).getTime(); + const answerEndTimeStamp = new Date( + `${ymdString} ${answerEndTime}`, + ).getTime(); + if (now < answerBegTimeStamp || now > answerEndTimeStamp) { + throw new HttpException( + '不在答题时段内', + EXCEPTION_CODE.RESPONSE_CURRENT_TIME_NOT_ALLOW, + ); + } + } + + // 提交总数限制 + const tLimit = responseSchema?.code?.baseConf?.tLimit || 0; + if (tLimit > 0) { + const nowSubmitCount = + (await this.surveyResponseService.getSurveyResponseTotalByPath( + surveyPath, + )) || 0; + if (nowSubmitCount >= tLimit) { + throw new HttpException( + '超出提交总数限制', + EXCEPTION_CODE.RESPONSE_OVER_LIMIT, + ); + } + } + + // 解密数据 + let decryptedData: Record = {}; + if (encryptType === ENCRYPT_TYPE.RSA && Array.isArray(data)) { + const sessionData = + await this.clientEncryptService.getEncryptInfoById(sessionId); + try { + const privateKeyObject = forge.pki.privateKeyFromPem( + sessionData.data.privateKey, + ); + let concatStr = ''; + for (const item of data) { + concatStr += privateKeyObject.decrypt( + forge.util.decode64(item), + 'RSA-OAEP', + ); + } + + decryptedData = JSON.parse(decodeURIComponent(concatStr)); + } catch (error) { + throw new HttpException( + '数据解密失败', + EXCEPTION_CODE.RESPONSE_DATA_DECRYPT_ERROR, + ); + } + } else { + decryptedData = JSON.parse(decodeURIComponent(data)); + } + + // 生成一个optionTextAndId字段,因为选项文本可能会改,该字段记录当前提交的文本 + const dataList = responseSchema.code.dataConf.dataList; + const optionTextAndId = dataList + .filter((questionItem) => { + return ( + Array.isArray(questionItem.options) && + questionItem.options.length > 0 && + decryptedData[questionItem.field] + ); + }) + .reduce((pre, cur) => { + const arr = cur.options.map((optionItem) => ({ + hash: optionItem.hash, + text: optionItem.text, + })); + pre[cur.field] = arr; + return pre; + }, {}); + + const secretKeys = []; + // 对用户提交的数据进行遍历处理 + for (const field in decryptedData) { + const value = decryptedData[field]; + const values = Array.isArray(value) ? value : [value]; + if (field in optionTextAndId) { + // 记录选项的提交数量,用于投票题回显、或者拓展上限限制功能 + const optionCountData: Record = + (await this.counterService.get({ + surveyPath, + key: field, + type: 'option', + })) || { total: 0 }; + optionCountData.total++; + for (const val of values) { + if (!optionCountData[val]) { + optionCountData[val] = 1; + } else { + optionCountData[val]++; + } + } + this.counterService.set({ + surveyPath, + key: field, + data: optionCountData, + type: 'option', + }); + } + } + + // 入库 + await this.surveyResponseService.createSurveyResponse({ + surveyPath: validationResult.surveyPath, + data: decryptedData, + clientTime, + difTime, + secretKeys, + surveyId: responseSchema.pageId, + optionTextAndId, + }); + + // 入库成功后,要把密钥删掉,防止被重复使用 + this.clientEncryptService.deleteEncryptInfo(sessionId); + + return { + code: 200, + msg: '提交成功', + }; + } +} diff --git a/server/src/modules/surveyResponse/controllers/surveyResponseUI.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponseUI.controller.ts new file mode 100644 index 00000000..8bbe6159 --- /dev/null +++ b/server/src/modules/surveyResponse/controllers/surveyResponseUI.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { join } from 'path'; + +@Controller() +export class SurveyResponseUIController { + constructor() {} + + @Get('/render/:surveyPath') + render(@Param('surveyPath') surveyPath: string, @Res() res: Response) { + res.sendFile(join(__dirname, 'src/../', 'public', 'render.html')); + } +} diff --git a/server/src/modules/surveyResponse/services/clientEncrypt.service.ts b/server/src/modules/surveyResponse/services/clientEncrypt.service.ts new file mode 100644 index 00000000..7d204d99 --- /dev/null +++ b/server/src/modules/surveyResponse/services/clientEncrypt.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; +import { ENCRYPT_TYPE } from 'src/enums/encrypt'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from 'src/enums'; + +@Injectable() +export class ClientEncryptService { + constructor( + @InjectRepository(ClientEncrypt) + private readonly clientEncryptRepository: MongoRepository, + ) {} + + addAes({ secretKey }) { + const encryptInfo = this.clientEncryptRepository.create({ + data: { + secretKey, + }, + type: ENCRYPT_TYPE.AES, + }); + return this.clientEncryptRepository.save(encryptInfo); + } + + addRsa({ publicKey, privateKey }) { + const encryptInfo = this.clientEncryptRepository.create({ + data: { + publicKey, + privateKey, + }, + type: ENCRYPT_TYPE.RSA, + }); + return this.clientEncryptRepository.save(encryptInfo); + } + + getEncryptInfoById(id) { + return this.clientEncryptRepository.findOne({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + }); + } + + deleteEncryptInfo(id: string) { + return this.clientEncryptRepository.updateOne( + { + _id: new ObjectId(id), + }, + { + $set: { + curStatus: { + status: RECORD_STATUS.REMOVED, + date: Date.now(), + }, + }, + }, + ); + } +} diff --git a/server/src/modules/surveyResponse/services/counter.service.ts b/server/src/modules/surveyResponse/services/counter.service.ts new file mode 100644 index 00000000..0b72ab82 --- /dev/null +++ b/server/src/modules/surveyResponse/services/counter.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { Counter } from 'src/models/counter.entity'; + +@Injectable() +export class CounterService { + constructor( + @InjectRepository(Counter) + private readonly counterRepository: MongoRepository, + ) {} + + async set({ + surveyPath, + key, + data, + type, + }: { + surveyPath: string; + key: string; + data: Record; + type: string; + }) { + return this.counterRepository.updateOne( + { + key, + surveyPath, + type, + }, + { + $set: { + key, + surveyPath, + type, + data, + }, + }, + { + upsert: true, + }, + ); + } + + async get({ surveyPath, key, type }): Promise> { + const countData = await this.counterRepository.findOne({ + where: { + key, + surveyPath, + type, + }, + }); + return countData?.data; + } + + async getAll({ surveyPath, keyList, type }) { + const res = await this.counterRepository.find({ + where: { + key: { $in: keyList }, + surveyPath, + type, + }, + }); + return res.reduce((pre, cur) => { + pre[cur.key] = cur.data; + return pre; + }, {}); + } +} diff --git a/server/src/modules/surveyResponse/services/responseScheme.service.ts b/server/src/modules/surveyResponse/services/responseScheme.service.ts new file mode 100644 index 00000000..701192bf --- /dev/null +++ b/server/src/modules/surveyResponse/services/responseScheme.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { RECORD_STATUS } from 'src/enums'; + +@Injectable() +export class ResponseSchemaService { + constructor( + @InjectRepository(ResponseSchema) + private readonly responseSchemaRepository: MongoRepository, + ) {} + + async publishResponseSchema({ title, surveyPath, code, pageId }) { + const clientSurvey = await this.responseSchemaRepository.findOne({ + where: { surveyPath }, + }); + + if (clientSurvey) { + clientSurvey.title = title; + clientSurvey.code = code; + clientSurvey.curStatus = { + status: RECORD_STATUS.PUBLISHED, + date: Date.now(), + }; + return this.responseSchemaRepository.save(clientSurvey); + } else { + const curStatus = { + status: RECORD_STATUS.PUBLISHED, + date: Date.now(), + }; + const newClientSurvey = this.responseSchemaRepository.create({ + title, + surveyPath, + code, + pageId, + curStatus, + statusList: [curStatus], + }); + return this.responseSchemaRepository.save(newClientSurvey); + } + } + + async getResponseSchemaByPath(surveyPath: string) { + return this.responseSchemaRepository.findOne({ + where: { surveyPath }, + }); + } + + async getResponseSchemaByPageId(pageId: string) { + return this.responseSchemaRepository.findOne({ + where: { pageId }, + }); + } + + async deleteResponseSchema({ surveyPath }) { + const responseSchema = await this.responseSchemaRepository.findOne({ + where: { surveyPath }, + }); + if (responseSchema) { + const newStatus = { + status: RECORD_STATUS.PUBLISHED, + date: Date.now(), + }; + responseSchema.curStatus = newStatus; + if (Array.isArray(responseSchema.statusList)) { + responseSchema.statusList.push(newStatus); + } else { + responseSchema.statusList = [newStatus]; + } + return this.responseSchemaRepository.save(responseSchema); + } + } +} diff --git a/server/src/modules/surveyResponse/services/surveyResponse.service.ts b/server/src/modules/surveyResponse/services/surveyResponse.service.ts new file mode 100644 index 00000000..ec7b68dd --- /dev/null +++ b/server/src/modules/surveyResponse/services/surveyResponse.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +@Injectable() +export class SurveyResponseService { + constructor( + @InjectRepository(SurveyResponse) + private readonly surveyResponseRepository: MongoRepository, + ) {} + + async createSurveyResponse({ + data, + secretKeys, + clientTime, + difTime, + surveyId, + surveyPath, + optionTextAndId, + }) { + const newSubmitData = this.surveyResponseRepository.create({ + surveyPath, + data, + secretKeys, + clientTime, + difTime, + pageId: surveyId, + optionTextAndId, + }); + + // 提交问卷 + return await this.surveyResponseRepository.save(newSubmitData); + } + + async getSurveyResponseTotalByPath(surveyPath: string) { + const count = await this.surveyResponseRepository.count({ + where: { + surveyPath, + 'curStatus.status': { + $ne: 'removed', + }, + }, + }); + return count; + } +} diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts new file mode 100644 index 00000000..e6f7edc7 --- /dev/null +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -0,0 +1,52 @@ +import { Module } from '@nestjs/common'; + +import { ResponseSchemaService } from './services/responseScheme.service'; +import { SurveyResponseService } from './services/surveyResponse.service'; +import { CounterService } from './services/counter.service'; +import { ClientEncryptService } from './services/clientEncrypt.service'; + +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { Counter } from 'src/models/counter.entity'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; + +import { ClientEncryptController } from './controllers/clientEncrpt.controller'; +import { CounterController } from './controllers/counter.controller'; +import { ResponseSchemaController } from './controllers/responseSchema.controller'; +import { SurveyResponseController } from './controllers/surveyResponse.controller'; +import { SurveyResponseUIController } from './controllers/surveyResponseUI.controller'; + +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ResponseSchema, + Counter, + SurveyResponse, + ClientEncrypt, + ]), + ConfigModule, + ], + controllers: [ + ClientEncryptController, + CounterController, + ResponseSchemaController, + SurveyResponseController, + SurveyResponseUIController, + ], + providers: [ + ResponseSchemaService, + SurveyResponseService, + CounterService, + ClientEncryptService, + ], + exports: [ + ResponseSchemaService, + SurveyResponseService, + CounterService, + ClientEncryptService, + ], +}) +export class SurveyResponseModule {} diff --git a/server/src/plugins/interface.ts b/server/src/plugins/interface.ts new file mode 100644 index 00000000..b402f704 --- /dev/null +++ b/server/src/plugins/interface.ts @@ -0,0 +1,6 @@ +export interface XiaojuSurveyPlugin { + beforeResponseDataCreate?(responseData); + afterResponseFind?(responseData); + desensitiveData?(data: Record); + genSurveyPath?(); +} diff --git a/server/src/plugins/pluginManager.provider.ts b/server/src/plugins/pluginManager.provider.ts new file mode 100644 index 00000000..4f13047a --- /dev/null +++ b/server/src/plugins/pluginManager.provider.ts @@ -0,0 +1,9 @@ +import xiaojuSurveyPluginManager, { + XiaojuSurveyPluginManager, +} from './pluginManager'; +import { Provider } from '@nestjs/common'; + +export const PluginManagerProvider: Provider = { + provide: XiaojuSurveyPluginManager, + useValue: xiaojuSurveyPluginManager, +}; diff --git a/server/src/plugins/pluginManager.ts b/server/src/plugins/pluginManager.ts new file mode 100644 index 00000000..a4ead04c --- /dev/null +++ b/server/src/plugins/pluginManager.ts @@ -0,0 +1,27 @@ +import { XiaojuSurveyPlugin } from './interface'; + +type AllowHooks = + | 'beforeResponseDataCreate' + | 'afterResponseDataReaded' + | 'desensitiveData' + | 'genSurveyPath'; + +export class XiaojuSurveyPluginManager { + private plugins: Array = []; + // 注册插件 + registerPlugin(...plugins: Array) { + this.plugins.push(...plugins); + } + + // 触发钩子 + async triggerHook(hookName: AllowHooks, data?: any) { + for (const plugin of this.plugins) { + if (plugin[hookName]) { + data = await plugin[hookName](data); + } + } + return data; + } +} + +export default new XiaojuSurveyPluginManager(); diff --git a/server/src/plugins/responseSecurityPlugin/index.ts b/server/src/plugins/responseSecurityPlugin/index.ts new file mode 100644 index 00000000..65cf1a69 --- /dev/null +++ b/server/src/plugins/responseSecurityPlugin/index.ts @@ -0,0 +1,67 @@ +import { XiaojuSurveyPlugin } from '../interface'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { + decryptData, + encryptData, + isDataSensitive, + desensitiveData, +} from './utils'; + +export class ResponseSecurityPlugin implements XiaojuSurveyPlugin { + constructor(private readonly secretKey: string) {} + beforeResponseDataCreate(responseData: SurveyResponse) { + const secretKeys = []; + if (responseData.data) { + for (const key in responseData.data) { + const value = responseData.data[key]; + const values = Array.isArray(value) ? value : [value]; + let needEncrypt = false; + for (const val of values) { + if (isDataSensitive(val)) { + needEncrypt = true; + break; + } + } + if (needEncrypt) { + secretKeys.push(key); + responseData.data[key] = Array.isArray(value) + ? value.map((item) => + encryptData(item, { + secretKey: this.secretKey, + }), + ) + : encryptData(value, { + secretKey: this.secretKey, + }); + } + } + } + responseData.secretKeys = secretKeys; + } + + afterResponseDataReaded(responseData: SurveyResponse) { + const secretKeys = responseData.secretKeys; + if (Array.isArray(secretKeys) && secretKeys.length > 0) { + for (const key of secretKeys) { + if (Array.isArray(responseData.data[key])) { + responseData.data[key] = responseData.data[key].map((item) => + decryptData(item, { secretKey: this.secretKey }), + ); + } else { + responseData.data[key] = decryptData(responseData.data[key], { + secretKey: this.secretKey, + }); + } + } + } + responseData.secretKeys = []; + } + + desensitiveData(data: Record) { + Object.keys(data).forEach((key) => { + if (isDataSensitive(data[key])) { + data[key] = desensitiveData(data[key]); + } + }); + } +} diff --git a/server/src/plugins/responseSecurityPlugin/utils.ts b/server/src/plugins/responseSecurityPlugin/utils.ts new file mode 100644 index 00000000..d2960e0d --- /dev/null +++ b/server/src/plugins/responseSecurityPlugin/utils.ts @@ -0,0 +1,66 @@ +import { load } from 'cheerio'; +import * as CryptoJS from 'crypto-js'; + +const phoneRegex = /^1[3456789]\d{9}$/; // 手机号码正则表达式 +const idCardRegex = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/; // 身份证号码正则表达式 +const addressRegex = /.*省|.*自治区|.*市|.*区|.*镇|.*县/; // 地址正则表达式 +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // 邮箱正则 +const genderArr = ['男', '女']; // 性别 + +const isPhone = (data: string) => phoneRegex.test(data); + +const isIdCard = (data: string) => idCardRegex.test(data); + +const isAddress = (data: string) => addressRegex.test(data); + +const isEmail = (data: string) => emailRegex.test(data); + +const isGender = (data: string) => genderArr.includes(data); + +const isString = (data) => { + return typeof data === 'string'; +}; + +export const isDataSensitive = (data): boolean => { + if (!isString(data)) { + return false; + } + const $ = load(data); + const text = $.text(); + const testArr = [isPhone, isIdCard, isAddress, isEmail, isGender]; + for (const test of testArr) { + if (test(text)) { + return true; + } + } + return false; +}; + +export const encryptData = (data, { secretKey }) => { + if (!isString(data)) { + return data; + } + return CryptoJS.AES.encrypt(data, secretKey).toString(); +}; + +export const decryptData = (data, { secretKey }) => { + if (!isString(data)) { + return data; + } + return CryptoJS.AES.decrypt(data, secretKey).toString(CryptoJS.enc.Utf8); +}; + +export const desensitiveData = (data: string): string => { + if (!isString(data)) { + return '*'; + } + const $ = load(data); + const text = $.text(); + if (text.length === 1) { + return '*'; + } + if (text.length === 2) { + return text[0] + '*'; + } + return text[0] + '***' + text[text.length - 1]; +}; diff --git a/server/src/plugins/surveyUtilPlugin/index.ts b/server/src/plugins/surveyUtilPlugin/index.ts new file mode 100644 index 00000000..9fb93354 --- /dev/null +++ b/server/src/plugins/surveyUtilPlugin/index.ts @@ -0,0 +1,12 @@ +import { XiaojuSurveyPlugin } from '../interface'; +import { customAlphabet } from 'nanoid'; + +const surveyPathAlphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +export class SurveyUtilPlugin implements XiaojuSurveyPlugin { + genSurveyPath() { + const id = customAlphabet(surveyPathAlphabet, 8); + return id(); + } +} diff --git a/server/src/registry.ts b/server/src/registry.ts deleted file mode 100644 index 24845529..00000000 --- a/server/src/registry.ts +++ /dev/null @@ -1,21 +0,0 @@ -class AppRegistry { - private apps: Record; - - constructor() { - this.apps = {}; - } - - registerApp(name: string, service: unknown) { - if (!this.apps[name]) { - this.apps[name] = service; - } - } - - getApp(name: string) { - return this.apps[name]; - } -} - -const appRegistry = new AppRegistry(); - -export default appRegistry; diff --git a/server/src/router.ts b/server/src/router.ts deleted file mode 100644 index d66978b4..00000000 --- a/server/src/router.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as Router from 'koa-router'; -import { Context } from 'koa'; -import { RouterOptions, surveyAppKey, surveyServerKey } from './decorator'; -import Security from './apps/security'; -import SurveyManage from './apps/surveyManage'; -import SurveyPublish from './apps/surveyPublish'; -import UI from './apps/ui'; -import User from './apps/user'; -import appRegistry from './registry'; - - -export async function initRouter(app) { - const rootRouter = new Router(); - const apps = [Security, SurveyManage, SurveyPublish, UI, User]; - - for (const subApp of apps) { - const instance = new subApp(); - - const moduleRouter = new Router(); - - const serverConfig: Map = instance[surveyServerKey]; - - for (const [serverName, serverValue] of serverConfig.entries()) { - if (serverValue.routerName) { - const method = serverValue.method || 'get'; - moduleRouter[method](serverValue.routerName, async (ctx: Context, next) => { - const ret = await instance[serverName]({ req: ctx.request, res: ctx.response }, next); - ctx.body = ret; - }); - } - } - rootRouter.use(subApp[surveyAppKey], moduleRouter.routes()); - - appRegistry.registerApp(instance.constructor.name.toLowerCase(), instance); - - } - // console.log(rootRouter); - app.use(rootRouter.routes()); -} \ No newline at end of file diff --git a/server/src/rpc.ts b/server/src/rpc.ts deleted file mode 100644 index bd8f4c9c..00000000 --- a/server/src/rpc.ts +++ /dev/null @@ -1,12 +0,0 @@ -import appRegistry from './registry'; - -export function rpcInvote(appServerName: string, params: P): Promise { - const appServerNameData = /^(\w+)\.(\w+)$/.exec(appServerName); - if (!appServerNameData) { - throw new Error('rpc调用必须按照app.function名方式填写,app和function名称只支持数字字母下划线'); - } - const appName = appServerNameData[1]; - const serverName = appServerNameData[2]; - const instance = appRegistry.getApp(appName.toLowerCase()); - return instance[serverName](params); -} \ No newline at end of file diff --git a/server/src/types/env.d.ts b/server/src/types/env.d.ts deleted file mode 100644 index 0d1a160d..00000000 --- a/server/src/types/env.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -export {}; - -declare global { - namespace NodeJS { - interface ProcessEnv { - // mongo - XIAOJU_SURVEY_MONGO_URL: string; - XIAOJU_SURVER_MONGO_DBNAME: string; - - // session - XIAOJU_SURVEY_SESSION_EXPIRE_TIME: string; - - // encrypt - XIAOJU_SURVEY_ENCRYPT_TYPE: string; - XIAOJU_SURVEY_ENCRYPT_SECRET_KEY: string; - XIAOJU_SURVEY_ENCRYPT_TYPE_LEN: string; - - // jwt - XIAOJU_SURVEY_JWT_SECRET: string; - XIAOJU_SURVEY_JWT_EXPIRES_IN: string; - } - } -} diff --git a/server/src/types/index.ts b/server/src/types/index.ts deleted file mode 100644 index fd66361b..00000000 --- a/server/src/types/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export enum DICT_TYPE { - danger = 'danger', - secret = 'secret' -} - -export enum SURVEY_STATUS { - new = 'new', - editing = 'editing', - pausing = 'pausing', - published = 'published', - removed = 'removed' -} - -export enum QUESTION_TYPE { - enps = 'enps', - nps = 'nps', - question = 'question', //通用问卷 - register = 'register', //报名 - vote = 'vote' //投票 -} - -export enum HISTORY_TYPE { - dailyHis = 'dailyHis', //保存历史 - publishHis = 'publishHis' //发布历史 -} - -export interface StatusObj { - id: string; - status: string; - date: number; -} - -export interface UserType { - _id: string; - username: string; - password: string; - curStatus: StatusObj; - createDate: number; -} - -export class CommonError extends Error { - code: number; - errmsg: number; - constructor(msg, code = 500) { - super(msg); - this.errmsg = msg; - this.code = code; - } -} - -export interface AnyType { - [key: string]: unknown; -} diff --git a/server/src/types/keyStore.ts b/server/src/types/keyStore.ts deleted file mode 100644 index 14a2c123..00000000 --- a/server/src/types/keyStore.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface KeyStore { - key: string; - surveyPath: string; - type: string; - data: unknown; - createDate: number; - updateDate: number; -} \ No newline at end of file diff --git a/server/src/apps/surveyPublish/utils/checkSign.ts b/server/src/utils/checkSign.ts similarity index 52% rename from server/src/apps/surveyPublish/utils/checkSign.ts rename to server/src/utils/checkSign.ts index f970eafd..cb473ec7 100644 --- a/server/src/apps/surveyPublish/utils/checkSign.ts +++ b/server/src/utils/checkSign.ts @@ -1,26 +1,17 @@ import { createHash } from 'crypto'; -import { CommonError } from '../../../types/index'; +import { cloneDeep } from 'lodash'; +import { EXCEPTION_CODE } from '../enums/exceptionCode'; +import { HttpException } from '../exceptions/httpException'; const hash256 = (text) => { return createHash('sha256').update(text).digest('hex'); }; -const undefinedToString = (data) => { - const res = {}; - for (const key in data) { - if (data[key] === undefined) { - res[key] = ''; - } else { - res[key] = data[key]; - } - } - return res; -}; const getSignByData = (sourceData, ts) => { - const data = undefinedToString(sourceData); + const data = cloneDeep(sourceData); const keysArr = Object.keys(data); keysArr.sort(); - const signArr = keysArr.map(key => { + const signArr = keysArr.map((key) => { if (typeof data[key] === 'string') { return `${key}=${encodeURIComponent(data[key])}`; } @@ -31,15 +22,19 @@ const getSignByData = (sourceData, ts) => { }; export const checkSign = (sourceData) => { - const sign = sourceData.sign; + const data = cloneDeep(sourceData); + const sign = data.sign; if (!sign) { - throw new CommonError('请求签名不存在'); + throw new HttpException( + '请求签名不存在', + EXCEPTION_CODE.RESPONSE_SIGN_ERROR, + ); } - delete sourceData.sign; + delete data.sign; const [inSign, ts] = sign.split('.'); - const realSign = getSignByData(sourceData, ts); + const realSign = getSignByData(data, ts); if (inSign !== realSign) { - throw new CommonError('请求签名异常'); + throw new HttpException('请求签名异常', EXCEPTION_CODE.RESPONSE_SIGN_ERROR); } return true; }; diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts new file mode 100644 index 00000000..1c815c8d --- /dev/null +++ b/server/src/utils/index.ts @@ -0,0 +1,10 @@ +import { customAlphabet } from 'nanoid'; + +const surveyPathAlphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +export function genSurveyPath({ size, prefix } = { size: 8, prefix: '' }) { + size = Number(size) ? Number(size) : 8; + const id = customAlphabet(`${prefix || ''}${surveyPathAlphabet}`, size); + return id(); +} diff --git a/server/src/utils/mongoService.ts b/server/src/utils/mongoService.ts deleted file mode 100644 index 34bd5b99..00000000 --- a/server/src/utils/mongoService.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Collection, MongoClient, ObjectId } from 'mongodb'; -import { CommonError } from '../types'; - - -// 定义一个通用类型,表示具有 _id 字段的对象 -interface ObjectWithId { - _id: ObjectId; - // 其他可能的属性... -} - -class MongoService { - isInit: boolean; - client: MongoClient; - dbName: string; - constructor({ url, dbName }) { - this.client = new MongoClient(url); - this.dbName = dbName; - } - - async getCollection({ collectionName }): Promise { - try { - // 设置一个6秒的计时器 - const timeoutPromise = new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error('连接超时')); - }, 6000); // 6秒超时 - }); - await Promise.race([this.client.connect(), timeoutPromise]); - } catch (error) { - throw new CommonError('数据库连接错误:' + error.message); - } - - try { - return this.client.db(this.dbName).collection(collectionName); - } catch (error) { - throw new CommonError(`get collection ${collectionName} error`); - } - } - - convertId2StringByDoc(doc: T): T { - return { ...doc, _id: doc._id.toString() }; - } - - convertId2StringByList(list: Array): Array { - return list.map(e => { - return this.convertId2StringByDoc(e); - }); - } - - getObjectIdByStr(str: string) { - return new ObjectId(str); - } -} - -export default MongoService; \ No newline at end of file diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 00000000..b1b67134 --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "scripts/*"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json index 11b5a06e..14e0f73b 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,16 +1,26 @@ { - "compilerOptions": { - "module": "commonjs", - "target":"ES2020", - "rootDir": "./src/", - "outDir": "./build/", - "declaration": true, - "experimentalDecorators": true, - "sourceMap": true, - "incremental":true, - "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": false, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "resolveJsonModule": true, + "esModuleInterop": true, + "paths": { + "src/": ["./src"] + } + } +} diff --git a/web/package.json b/web/package.json index 8a55d3e4..048c7fb7 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "crypto-js": "^4.2.0", "element-ui": "^2.15.13", "moment": "^2.29.4", + "node-forge": "^1.3.1", "qrcode": "^1.5.3", "vue": "^2.7.14", "vue-router": "^3.5.1", @@ -44,10 +45,10 @@ "prettier": "^2.4.1", "sass": "^1.32.7", "sass-loader": "^12.0.0", + "speed-measure-webpack-plugin": "^1.5.0", "style-resources-loader": "^1.5.0", "vue-style-loader": "^4.1.3", - "vue-template-compiler": "^2.7.14", - "speed-measure-webpack-plugin": "^1.5.0" + "vue-template-compiler": "^2.7.14" }, "engines": { "node": ">=14.21.0", diff --git a/web/src/management/api/analysis.js b/web/src/management/api/analysis.js index c8e6d16b..c5648ec7 100644 --- a/web/src/management/api/analysis.js +++ b/web/src/management/api/analysis.js @@ -1,7 +1,7 @@ import axios from './base'; export const getRecycleList = (data) => { - return axios.get('/surveyManage/data', { + return axios.get('/survey/dataStatistic/dataTable', { params: { pageSize: 10, ...data, diff --git a/web/src/management/api/user.js b/web/src/management/api/auth.js similarity index 54% rename from web/src/management/api/user.js rename to web/src/management/api/auth.js index 6883867d..2cad287b 100644 --- a/web/src/management/api/user.js +++ b/web/src/management/api/auth.js @@ -1,9 +1,9 @@ import axios from './base'; export const register = (data) => { - return axios.post('/user/register', data); + return axios.post('/auth/register', data); }; export const login = (data) => { - return axios.post('/user/login', data); + return axios.post('/auth/login', data); }; diff --git a/web/src/management/api/captcha.js b/web/src/management/api/captcha.js index 55e589b3..0c2cd172 100644 --- a/web/src/management/api/captcha.js +++ b/web/src/management/api/captcha.js @@ -1,5 +1,5 @@ import axios from './base'; export const refreshCaptcha = ({ captchaId }) => { - return axios.post('/user/captcha', { captchaId }); + return axios.post('/auth/captcha', { captchaId }); }; diff --git a/web/src/management/api/skin.js b/web/src/management/api/skin.js index 9f658ff3..67ff23e2 100644 --- a/web/src/management/api/skin.js +++ b/web/src/management/api/skin.js @@ -1,5 +1,5 @@ import axios from './base'; export const getBannerData = () => { - return axios.get('/surveyManage/getBannerData'); + return axios.get('/survey/getBannerData'); }; diff --git a/web/src/management/api/survey.js b/web/src/management/api/survey.js index e92ec532..fb758ede 100644 --- a/web/src/management/api/survey.js +++ b/web/src/management/api/survey.js @@ -1,18 +1,18 @@ import axios from './base'; -export const getSurveyList = (curPage, filter, order) => { - return axios.get('/surveyManage/list', { +export const getSurveyList = ({ curPage, filter, order }) => { + return axios.get('/survey/getList', { params: { pageSize: 10, curPage, filter, - order + order, }, }); }; export const getSurveyById = (id) => { - return axios.get('/surveyManage/get', { + return axios.get('/survey/getSurvey', { params: { surveyId: id, }, @@ -20,21 +20,21 @@ export const getSurveyById = (id) => { }; export const saveSurvey = ({ surveyId, configData }) => { - return axios.post('/surveyManage/saveConf', { surveyId, configData }); + return axios.post('/survey/updateConf', { surveyId, configData }); }; export const publishSurvey = ({ surveyId }) => { - return axios.post('/surveyManage/publish', { + return axios.post('/survey/publishSurvey', { surveyId, }); }; export const createSurvey = (data) => { - return axios.post('/surveyManage/add', data); + return axios.post('/survey/createSurvey', data); }; export const getSurveyHistory = ({ surveyId, historyType }) => { - return axios.get('/surveyManage/getHistoryList', { + return axios.get('/surveyHisotry/getList', { params: { surveyId, historyType, @@ -43,15 +43,11 @@ export const getSurveyHistory = ({ surveyId, historyType }) => { }; export const deleteSurvey = (surveyId) => { - return axios.post('/surveyManage/delete', { + return axios.post('/survey/deleteSurvey', { surveyId, }); }; export const updateSurvey = (data) => { - return axios.post('/surveyManage/update', data); -}; - -export const copySurvey = (data) => { - return axios.post('/surveyManage/create', data); + return axios.post('/survey/updateMeta', data); }; diff --git a/web/src/management/pages/analysis/components/table.vue b/web/src/management/pages/analysis/components/table.vue index ae09397d..dd44b1d5 100644 --- a/web/src/management/pages/analysis/components/table.vue +++ b/web/src/management/pages/analysis/components/table.vue @@ -34,9 +34,9 @@ diff --git a/web/src/management/pages/list/components/state.vue b/web/src/management/pages/list/components/state.vue index 93fc2d00..19cd2c30 100644 --- a/web/src/management/pages/list/components/state.vue +++ b/web/src/management/pages/list/components/state.vue @@ -1,7 +1,7 @@ \ No newline at end of file +.el-button { + margin-right: 20px; + font-size: 14px; + color: #4a4c5b; +} + diff --git a/web/src/management/pages/list/components/textSearch.vue b/web/src/management/pages/list/components/textSearch.vue index a2a16423..b60f594c 100644 --- a/web/src/management/pages/list/components/textSearch.vue +++ b/web/src/management/pages/list/components/textSearch.vue @@ -1,68 +1,68 @@ diff --git a/web/src/management/pages/list/components/textSelect.vue b/web/src/management/pages/list/components/textSelect.vue index 261d13cd..6adf326f 100644 --- a/web/src/management/pages/list/components/textSelect.vue +++ b/web/src/management/pages/list/components/textSelect.vue @@ -1,59 +1,59 @@ \ No newline at end of file +.el-select { + width: 105px; + line-height: 35px; + margin-right: 20px; + ::v-deep .el-input__inner { + border: none; + height: 35px; + // line-height: 35px; + } + ::v-deep .el-icon-arrow-up:before { + position: relative; + top: -2px; + } +} + diff --git a/web/src/management/pages/list/config/index.js b/web/src/management/pages/list/config/index.js index 29a7b728..1750c20e 100644 --- a/web/src/management/pages/list/config/index.js +++ b/web/src/management/pages/list/config/index.js @@ -5,49 +5,43 @@ export const type = { register: '在线报名', }; -export const thead = { - type: '类型', - title: '标题', - remark: '备注', - state: '状态', - owner: '所有者', - creator: '创建人', - tags: '标签', - updateDate: '更新时间', - createDate: '创建时间', - collectCount: '回收数', -}; - export const fieldConfig = { type: { + title: '类型', key: 'type', width: 150, comp: 'tag', }, title: { + title: '标题', key: 'title', width: 240, tip: true, }, remark: { + title: '备注', key: 'remark', width: 200, tip: true, }, state: { + title: '状态', key: 'state', width: 140, comp: 'state', }, - creator: { - key: 'creator', + owner: { + title: '所有者', + key: 'owner', width: 140, }, updateDate: { - key: 'updateDate', + title: '更新时间', + key: 'curStatus.date', minWidth: 200, }, createDate: { + title: '创建时间', key: 'createDate', minWidth: 200, }, @@ -79,31 +73,31 @@ export const questionTypeSelect = { value: [ { value: '', - label: '全部类型' - }, + label: '全部类型', + }, { value: 'normal', - label: '基础调查' - }, + label: '基础调查', + }, // { // value: 'exam', // label: '在线考试' - // }, + // }, // { // value: 'nps', // label: 'NPS评分' - // }, + // }, { value: 'vote', - label: '投票评选' + label: '投票评选', }, { value: 'register', - label: '在线报名' + label: '在线报名', }, ], - default: '' -} + default: '', +}; // 问卷状态 export const curStatusSelect = { @@ -111,28 +105,28 @@ export const curStatusSelect = { value: [ { value: '', - label: '全部状态' + label: '全部状态', }, { value: 'new', - label: '未发布' - }, + label: '未发布', + }, { value: 'published', - label: '已发布' - }, + label: '已发布', + }, { value: 'editing', - label: '修改中' - } + label: '修改中', + }, ], - default: '' -} + default: '', +}; export const selectOptionsDict = Object.freeze({ questionType: questionTypeSelect, - 'curStatus.status': curStatusSelect -}) + 'curStatus.status': curStatusSelect, +}); export const buttonOptionsDict = Object.freeze({ 'curStatus.date': { @@ -141,17 +135,17 @@ export const buttonOptionsDict = Object.freeze({ { name: 'el-icon-sort', effectValue: '', - isDefaultValue: true + isDefaultValue: true, }, { name: 'el-icon-sort-up', - effectValue: 1 + effectValue: 1, }, { name: 'el-icon-sort-down', effectValue: -1, }, - ] + ], }, createDate: { label: '创建时间', @@ -159,19 +153,16 @@ export const buttonOptionsDict = Object.freeze({ { name: 'el-icon-sort', effectValue: '', - }, { name: 'el-icon-sort-up', - effectValue: 1 + effectValue: 1, }, { name: 'el-icon-sort-down', effectValue: -1, - isDefaultValue: true + isDefaultValue: true, }, - ] - } -}) - - + ], + }, +}); diff --git a/web/src/management/pages/login/index.vue b/web/src/management/pages/login/index.vue index 06e9af9e..607bc39f 100644 --- a/web/src/management/pages/login/index.vue +++ b/web/src/management/pages/login/index.vue @@ -63,7 +63,7 @@