Compare commits

...

7 Commits

Author SHA1 Message Date
sudoooooo
ab17f0ef97 fix: 修复图片路径问题 2024-02-06 21:47:28 +08:00
luch
5e8de461bb fix: 修改列表页筛选bug 2024-02-06 21:42:15 +08:00
sudoooooo
d8b80bca1b feat: 修改traceid生成规则,修改readme、图片和windows兼容 2024-02-06 21:01:58 +08:00
sudoooooo
c942854477 fix: 修复已保存数据导致资源丢失问题 2024-02-05 16:39:40 +08:00
Weiguo Wang
3992c23f03 performance: 压缩图片,优化编辑器图片懒加载 2024-02-05 15:13:21 +08:00
luch
6ce03e6ab9
fix: 修改返回页面路径问题 (#58) 2024-01-30 23:14:13 +08:00
luch
393528fbe7
feat: 升级server到nestjs框架 (#57) 2024-01-30 22:19:45 +08:00
301 changed files with 5734 additions and 3272 deletions

3
.gitignore vendored
View File

@ -2,6 +2,7 @@
node_modules
dist
package-lock.json
# local env files
.env.local
@ -23,4 +24,4 @@ pnpm-debug.log*
*.sln
*.sw?
.history
.history

View File

@ -1,16 +1,5 @@
# 镜像集成
FROM ubuntu:latest
# 安装依赖
RUN apt-get -y update
RUN apt-get -y install wget gcc
# 安装node环境
ENV NODE_VERSION v18.17.1
RUN mkdir -p /node/$NODE_VERSION
RUN wget https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.gz
RUN tar xzf node-$NODE_VERSION-linux-x64.tar.gz -C /node/
ENV PATH /node/node-$NODE_VERSION-linux-x64/bin:$PATH
FROM node:16
# 设置工作区间
WORKDIR /xiaoju-survey
@ -22,10 +11,10 @@ RUN npm config set registry https://registry.npmjs.org/
# 安装项目依赖
RUN cd /xiaoju-survey/web && npm install && npm run build
# 用了后端服务代理启动建议使用nginx启动
RUN cd /xiaoju-survey && cp -af ./web/dist/* ./server/public/
RUN cd /xiaoju-survey && cp -af ./web/dist/* ./server/src/apps/ui/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

View File

@ -23,7 +23,7 @@
<br />
&ensp;&ensp;**XiaoJuSurvey**是一套轻量、安全的问卷系统,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的**问卷系统**,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
&ensp;&ensp;系统已沉淀40+种题型累积精选模板100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
@ -34,16 +34,26 @@
- 多样化题型:单行输入框、多行输入框、单项选择、多项选择、判断题、评分、投票
_(更多题型将陆续开放,也欢迎您参与共建提交自定义题型)_
_(更多题型将陆续开放。快速[自定义题型](https://xiaojusurvey.didi.cn/docs/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E9%A2%98%E5%9E%8B%E6%89%A9%E5%B1%95))_
- 用户管理:登录、注册、权限管理
- 数据安全:传输加密、脱敏等
<img src="https://img-hxy021.didistatic.com/static/starimg/img/nJ5fyGhocH1698903177499.png" width="900" />
> 查阅[官方Feature](https://github.com/didi/xiaoju-survey/issues/45)
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/dd427471-368d-49d9-bc44-13c34d84e3be" width="700" />
_**(个人和企业用户均可快速构建特定领域的调研类解决方案。)**_
# 技术
Web端Vue2Vue3版本24年上半年推出+ ElementUI
Server端Nestjs + MongoDB
架构:[架构解读](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E6%9E%B6%E6%9E%84)
# 项目优势
**一、具备全面的综合性和专业性**
@ -91,7 +101,7 @@ _**(个人和企业用户均可快速构建特定领域的调研类解决方案
# 快速启动
Node版本>=14.21.0
Node版本 >= 16.x
[查看环境准备指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
复制工程
@ -102,7 +112,7 @@ git clone git@github.com:didi/xiaoju-survey.git
## 服务端启动
### 方案一、快速启动,无需安装数据库
_便于快速预览工程对于正式项目需要使用方案二。_
> _便于快速预览工程对于正式项目需要使用方案二。_
#### 1、安装依赖
```shell
@ -115,33 +125,17 @@ npm install
npm run local
```
:cyclone:NOTE
服务运行依赖 [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server)
> 服务运行依赖 [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server)
>
> 1、数据保存在内存中重启服务会更新数据。<br />2、启动内存服务器新实例时如果找不到MongoDB二进制文件会自动下载因此首次可能需要一些时间。
### 方案二、(推荐)
### 方案二、(生产推荐)
#### 1、启动数据库
项目使用MongoDB
> 项目使用MongoDB[MongoDB安装指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83)
> 没有安装可以查看 [MongoDB安装指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83)
```
mongod --dbpath ~/data/db --logpath ~/data/log/mongodb/mongo.log --fork
```
验证启动
```
ps aux | grep -v grep | grep mongod
```
<img src="https://img-hxy021.didistatic.com/static/starimg/img/q2kOGoR8MV1700072541114.jpg" width="800" />
> 工程默认的mongo链接如下需要修改可查看[配置修改指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83#%E9%85%8D%E7%BD%AE)
```
mongodb://localhost:27017
```
启动和配置数据库,查看[MongoDB启动](http://localhost:5000/docs/next/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83#%E4%BA%94%E5%90%AF%E5%8A%A8)
#### 2、安装依赖
```shell
@ -176,7 +170,6 @@ npm run serve
[http://localhost:8080/render/:surveyPath](http://localhost:8080/render/:surveyPath)
# 交流群
## 微信
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="300" />

View File

@ -1,6 +1,5 @@
version: '3.6'
version: "3.6"
services:
mongo:
image: mongo:4
container_name: xiaoju-survey-mongo
@ -16,11 +15,13 @@ 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 会根据容器名称自动处理
links:
- mongo:mongo
depends_on:
@ -33,5 +34,5 @@ volumes:
networks:
xiaoju-survey:
name: xiaoju-survey
driver: bridge
name: xiaoju-survey
driver: bridge

View File

@ -1,3 +1,3 @@
#! /bin/bash
cd /xiaoju-survey/server
npm run start
npm run start:prod

12
server/.env Normal file
View File

@ -0,0 +1,12 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey
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

0
server/.env.development Normal file
View File

View File

@ -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

0
server/.env.production Normal file
View File

25
server/.eslintrc.js Normal file
View File

@ -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',
},
};

View File

@ -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"]
}
}

122
server/.gitignore vendored
View File

@ -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

4
server/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

8
server/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -1,52 +1,90 @@
{
"name": "survey-template",
"version": "1.0.0",
"description": "survey server template",
"main": "index.js",
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"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",
"dev": "npm run start:dev",
"start:dev": "cross-env NODE_ENV=development nest start --watch",
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
"start:prod": "NODE_ENV=production node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "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/(.*)$": "<rootDir>/$1"
}
}
}

View File

@ -1,4 +1,4 @@
#main{
#main {
width: 100wh;
height: 100vh;
display: flex;
@ -6,7 +6,8 @@
justify-content: center;
align-items: center;
}
.title{
.title {
font-size: 20px;
color: #4A4C5B;
}

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷管理端</title>
<link rel="stylesheet" href="./commom.css">
</head>
<body>
<div id="main">
<img src="./nodata.png" alt="">
<p class="title">暂无数据</p>
</div>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -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);
});
});

View File

@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller()
export class AppController {}

115
server/src/app.module.ts Normal file
View File

@ -0,0 +1,115 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ResponseSecurityPlugin } from './securityPlugin/responseSecurityPlugin';
import { SurveyUtilPlugin } from './securityPlugin/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 './securityPlugin/pluginManager.provider';
import { LogRequestMiddleware } from './middlewares/logRequest.middleware';
import { XiaojuSurveyPluginManager } from './securityPlugin/pluginManager';
import { Logger } from './logger';
@Module({
imports: [
ConfigModule.forRoot({}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const url = await configService.get<string>('XIAOJU_SURVEY_MONGO_URL');
const authSource =
(await configService.get<string>(
'XIAOJU_SURVEY_MONGO_AUTH_SOURCE',
)) || '';
const database = await configService.get<string>(
'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<string>(
'XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY',
),
),
new SurveyUtilPlugin(),
);
this.logger.init({
filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'),
});
}
}

View File

@ -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,
};
}

View File

@ -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];
}
}

View File

@ -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);

View File

@ -1,6 +0,0 @@
export interface DataSecurityPlugin {
isDataSensitive(data: string): boolean;
encryptData(data: string): string;
decryptData(data: string): string;
desensitiveData(data: string): string;
}

View File

@ -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 });

View File

@ -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);
}
}

View File

@ -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();

View File

@ -1,18 +0,0 @@
export function participle({ content, minLen, maxLen }: { content: string, minLen?: number, maxLen?: number }) {
const keys: Array<string> = [];
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';

View File

@ -1,7 +0,0 @@
import { mongo } from '../../../config';
export function getConfig() {
return {
mongo,
};
}

View File

@ -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 });

View File

@ -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<FilterCondition>;
}
type FilterCondition = {
field: string;
comparator?: string;
value: string & Array<FilterItem>;
}
@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<FilterItem>) {
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<string, string>>; } & Record<string, string>);
}
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
};
}
}

View File

@ -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();

View File

@ -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<unknown, { result: boolean }>('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<unknown> {
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();

View File

@ -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<unknown, { result: UserType }>('user.getUserByToken', {
params: { token },
context: req
});
return rpcResult.result;
}
}
export const userService = new UserService();

View File

@ -1,174 +0,0 @@
{
"temp": {
"key": "default",
"name": "默认分类",
"list": [{
"src": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg",
"title": "1"
}]
},
"activity": {
"key": "activity",
"name": "节日",
"list": [{
"src": "/imgs/skin/Ixx8hqiwwk1660979120801.jpg",
"title": "1"
}]
},
"creative": {
"key": "creative",
"name": "创意",
"list": [{
"src": "/imgs/skin/PLwNH1rAie1558430219772.jpg",
"title": "1"
}, {
"src": "/imgs/skin/NnXsAOhBNm1558430219312.jpg",
"title": "2"
}, {
"src": "/imgs/skin/ujpUoWqhw31558430220124.jpg",
"title": "3"
}, {
"src": "/imgs/skin/5OCvbjqJQm1558430220362.jpg",
"title": "4"
}, {
"src": "/imgs/skin/0k7Jg7In8I1558430221154.jpg",
"title": "5"
}, {
"src": "/imgs/skin/UH0A8DbTai1558430221033.jpg",
"title": "6"
}, {
"src": "/imgs/skin/FRIzPC6ZtN1558430221344.jpg",
"title": "7"
}]
},
"scenery": {
"key": "scenery",
"name": "风景",
"list": [{
"src": "/imgs/skin/SyiLRcukyE1558430525760.jpg",
"title": "1"
}, {
"src": "/imgs/skin/sqYig4AcWr1558430525663.jpg",
"title": "2"
}, {
"src": "/imgs/skin/ElNeqJT2I21558430526165.jpg",
"title": "3"
}, {
"src": "/imgs/skin/CxQkSU6AY21558430526163.jpg",
"title": "4"
}, {
"src": "/imgs/skin/VTUwbp6vY61558430527320.jpg",
"title": "5"
}, {
"src": "/imgs/skin/SHs0K703Yn1558430527218.jpg",
"title": "6"
}, {
"src": "/imgs/skin/oVTedX9V4s1558430527671.jpg",
"title": "7"
}]
},
"transportation": {
"key": "transportation",
"name": "交通",
"list": [{
"src": "/imgs/skin/XYKqJZuMig1558430904735.jpg",
"title": "1"
}, {
"src": "/imgs/skin/GnPatsr48Z1558430904680.jpg",
"title": "2"
}, {
"src": "/imgs/skin/UqIvVvEXAK1558430905204.jpg",
"title": "3"
}, {
"src": "/imgs/skin/PUssufh5uI1558430905104.jpg",
"title": "4"
}, {
"src": "/imgs/skin/O409pRTDlW1558430905738.jpg",
"title": "5"
}, {
"src": "/imgs/skin/A9FzlbYXqI1558430905739.jpg",
"title": "6"
}, {
"src": "/imgs/skin/HN9YGctDeF1558430906686.jpg",
"title": "7"
}]
},
"delicacy": {
"key": "delicacy",
"name": "美食",
"list": [{
"src": "/imgs/skin/lE6PSclCcU1558434536703.jpg",
"title": "1"
}, {
"src": "/imgs/skin/OnSdbm7u6n1558434536641.jpg",
"title": "2"
}, {
"src": "/imgs/skin/N9Z2ZuyO731558434537314.jpg",
"title": "3"
}, {
"src": "/imgs/skin/YP9PoW8pX51558434537301.jpg",
"title": "4"
}, {
"src": "/imgs/skin/zUtDv378bg1558434538351.jpg",
"title": "5"
}, {
"src": "/imgs/skin/gY1JljCow21558434538303.jpg",
"title": "6"
}, {
"src": "/imgs/skin/oOjHPbABdd1558434538864.jpg",
"title": "7"
}]
},
"business": {
"key": "business",
"name": "商务",
"list": [{
"src": "/imgs/skin/3ABKqvDaVn1558514860472.jpg",
"title": "1"
}, {
"src": "/imgs/skin/OewuaQmWoq1558514860285.jpg",
"title": "2"
}, {
"src": "/imgs/skin/HuVqqtbFjs1558514860570.jpg",
"title": "3"
}, {
"src": "/imgs/skin/icSlqsr0uZ1558514860875.jpg",
"title": "4"
}, {
"src": "/imgs/skin/Qu9rg33wmq1558514861015.jpg",
"title": "5"
}, {
"src": "/imgs/skin/145gBCRtNP1558514861211.jpg",
"title": "6"
}, {
"src": "/imgs/skin/ykWLFV0QWj1558514861444.jpg",
"title": "7"
}]
},
"campus": {
"key": "campus",
"name": "校园",
"list": [{
"src": "/imgs/skin/4aWi5JxG471558514268698.jpg",
"title": "1"
}, {
"src": "/imgs/skin/j8C2OBP7WK1558514268563.jpg",
"title": "2"
}, {
"src": "/imgs/skin/q3uJoQhYsR1558514268877.jpg",
"title": "3"
}, {
"src": "/imgs/skin/W5PPlNsmsr1558514269088.jpg",
"title": "4"
}, {
"src": "/imgs/skin/6xQk1IAmKt1558514269874.jpg",
"title": "5"
}, {
"src": "/imgs/skin/XQE2iyF0rj1558514269935.jpg",
"title": "6"
}, {
"src": "/imgs/skin/POHlQiSwPR1558514270379.jpg",
"title": "7"
}]
}
}

View File

@ -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);
}

View File

@ -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<T = unknown>(validationResult: Joi.ValidationResult<T>): 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<string> {
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);
}
});
});
};

View File

@ -1,9 +0,0 @@
import { mongo, session, encrypt } from '../../../config';
export function getConfig() {
return {
mongo,
session,
encrypt,
};
}

View File

@ -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 });

View File

@ -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: '提交成功',
};
}
}

View File

@ -1,66 +0,0 @@
import { mongo } from '../db/mongo';
import { KeyStore } from '../../../types/keyStore';
// 该服务用于模拟redis
class SurveyKeyStoreService {
getKeyStoreResult(surveyKeyStoreData: Array<KeyStore>) {
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<KeyStore> = 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();

View File

@ -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<string> }) {
return await surveyKeyStoreService.getAll({ surveyPath, keyList: voteKeyList, type: 'vote' });
}
}
export const surveyPublishService = new SurveyPublishService();

View File

@ -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();

View File

@ -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<T = unknown>(validationResult: Joi.ValidationResult<T>): 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<string> = [];
for (let i = 0; i < length; i++) {
charList.push(Math.floor(Math.random() * 16).toString(16));
}
return charList.join('');
}

View File

@ -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);
}
}

View File

@ -1,8 +0,0 @@
import { mongo, jwt } from '../../../config';
export function getConfig() {
return {
mongo,
jwt,
};
}

View File

@ -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 });

View File

@ -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,
},
};
}
}

View File

@ -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();

View File

@ -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();

View File

@ -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<T = unknown>(validationResult: Joi.ValidationResult<T>): T {
if (validationResult.error) {
throw new CommonError(validationResult.error.details.map(e => e.message).join());
}
return validationResult.value;
}

View File

@ -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,
}

View File

@ -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<string, RouterOptions>();
}
target[surveyServerKey].set(
propertyKey,
options
);
};
}

View File

@ -0,0 +1,4 @@
export enum ENCRYPT_TYPE {
AES = 'aes',
RSA = 'rsa',
}

View File

@ -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, // 问卷已删除
}

15
server/src/enums/index.ts Normal file
View File

@ -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', //发布历史
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<Response>();
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,
});
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<boolean> {
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<string>('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;
}
}

View File

@ -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();

View File

@ -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;

View File

@ -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();

View File

@ -0,0 +1,8 @@
import { Provider } from '@nestjs/common';
import logger, { Logger } from './index';
export const LoggerProvider: Provider = {
provide: Logger,
useValue: logger,
};

27
server/src/logger/util.ts Normal file
View File

@ -0,0 +1,27 @@
let count = 999;
const getCountStr = () => {
count++;
if (count > 9000) {
count = 1000;
}
return count.toString();
};
export const genTraceId = ({ ip }) => {
// ip转16位 + 当前时间戳(毫秒级)+自增序列1000开始自增到9000+ 当前进程id的后5位
ip = ip.replace('::ffff:', '');
let ipArr;
if (ip.indexOf(':') > 0) {
ipArr = ip.split(':').map((segment) => {
// 将IPv6每个段转为16位并补0到长度为4
return parseInt(segment, 16).toString(16).padStart(4, '0');
});
} else {
ipArr = ip
.split('.')
.map((item) => parseInt(item).toString(16).padStart(2, '0'));
}
return `${ipArr.join('')}${Date.now().toString()}${getCountStr()}${process.pid.toString().slice(-5)}`;
};

11
server/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const PORT = process.env.PORT || 3000;
const app = await NestFactory.create(AppModule);
await app.listen(PORT);
console.log(`server is running at: http://127.0.0.1:${PORT}`);
}
bootstrap();

View File

@ -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;
}
};
}

View File

@ -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({ ip });
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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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<string, any>;
@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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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 '../securityPlugin/pluginManager';
@Entity({ name: 'surveySubmit' })
export class SurveyResponse {
@ObjectIdColumn()
_id: ObjectId;
@Column()
pageId: string;
@Column()
surveyPath: string;
@Column('jsonb')
data: Record<string, any>;
@Column()
difTime: number;
@Column()
clientTime: number;
@Column('jsonb')
secretKeys: Array<string>;
@Column('jsonb')
optionTextAndId: Record<string, any>;
@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;
pluginManager.triggerHook('beforeResponseDataCreate', this);
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
@AfterLoad()
onDataLoaded() {
pluginManager.triggerHook('afterResponseDataReaded', this);
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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>(AuthController);
userService = module.get<UserService>(UserService);
captchaService = module.get<CaptchaService>(CaptchaService);
authService = module.get<AuthService>(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),
);
});
});
});

View File

@ -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<string>('XIAOJU_SURVEY_JWT_SECRET'),
expiresIn: this.configService.get<string>(
'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<string>('XIAOJU_SURVEY_JWT_SECRET'),
expiresIn: this.configService.get<string>(
'XIAOJU_SURVEY_JWT_EXPIRES_IN',
),
},
);
// 验证过的验证码要删掉,防止被别人保存重复调用
this.captchaService.deleteCaptcha(userInfo.captchaId);
} catch (error) {
throw new Error(
'generateToken erro:' +
error.message +
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET') +
this.configService.get<string>('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,
},
};
}
}

View File

@ -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>(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,
});
});
});
});

View File

@ -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,
});
}
}

View File

@ -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<Captcha>;
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>(CaptchaService);
captchaRepository = module.get<MongoRepository<Captcha>>(
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 },
});
});
});
});

View File

@ -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<Captcha>,
) {}
async createCaptcha(captchaText: string): Promise<Captcha> {
const captcha = this.captchaRepository.create({
text: captchaText,
});
return this.captchaRepository.save(captcha);
}
async getCaptcha(id: string): Promise<Captcha | undefined> {
return this.captchaRepository.findOne({ where: { _id: new ObjectId(id) } });
}
async deleteCaptcha(id: string): Promise<void> {
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();
}
}

View File

@ -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<User>,
) {}
private hash256(text) {
return createHash('sha256').update(text).digest('hex');
}
async createUser(userInfo: {
username: string;
password: string;
}): Promise<User> {
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<User | undefined> {
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;
}
}

View File

@ -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>(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);
});
});
});

View File

@ -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>(SurveyController);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
surveyConfService = module.get<SurveyConfService>(SurveyConfService);
responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
contentSecurityService = module.get<ContentSecurityService>(
ContentSecurityService,
);
surveyHistoryService =
module.get<SurveyHistoryService>(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.webp', 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,
});
});
});
});

View File

@ -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/securityPlugin/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,
},
};
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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<FilterCondition>;
};
type FilterCondition = {
field: string;
comparator?: string;
value: string & Array<FilterItem>;
};
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<FilterItem>) {
const allowFilterField = [
'title',
'remark',
'surveyType',
'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<string, string>> } & Record<string, string>,
);
}
private getOrder(order: Array<OrderItem>) {
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;
}, {});
}
}

View File

@ -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(process.cwd(), 'public', 'management.html'));
}
@Get('/management/:surveyId')
management(@Param('surveyId') surveyId: string, @Res() res: Response) {
res.sendFile(join(process.cwd(), 'public', 'management.html'));
}
}

View File

@ -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<Word>,
) {}
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;
}
}

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