diff --git a/.gitignore b/.gitignore index e0d3138e..98aad444 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ pnpm-debug.log* *.sw? .history + components.d.ts # 默认的上传文件夹 userUpload +exportfile +yarn.lock \ No newline at end of file diff --git a/DATA_COLLECTION.md b/DATA_COLLECTION.md new file mode 100644 index 00000000..bdc5e6fa --- /dev/null +++ b/DATA_COLLECTION.md @@ -0,0 +1,22 @@ +# Important Disclosure re:XIAOJUSURVEY Data Collection + +XIAOJUSURVEY is open-source software developed and maintained by XIAOJUSURVEY Team and available at https://github.com/didi/xiaoju-survey. +We hereby state the purpose and reason for collecting data. + +## Purpose of data collection + +Data collected is used to help improve XIAOJUSURVEY for all users. It is important that our team understands the usage patterns as soon as possible, so we can best decide how to design future features and prioritize current work. + +## Types of data collected + +XIAOJUSURVEY just collects data about version's information. The data collected is subsequently reported to the XIAOJUSURVEY's backend services. + +All data collected will be used exclusively by the XIAOJUSURVEY team for analytical purposes only. The data will be neither accessible nor sold to any third party. + +## Sensitive data + +XIAOJUSURVEY will never collect and/or report sensitive information, such as private keys, API keys, or passwords. + +## How do I opt-in to or opt-out of data sharing? + +See [docs](https://xiaojusurvey.didi.cn/docs/next/community/%E6%95%B0%E6%8D%AE%E9%87%87%E9%9B%86%E5%A3%B0%E6%98%8E) for information on configuring this functionality. diff --git a/README.md b/README.md index d475636b..f7c7f193 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,9 @@ npm run local #### 1、配置数据库 -> 项目使用 MongoDB,需要提前准备,请查看[如何拥有 MongoDB 指南](./数据库#安装) +> 项目使用 MongoDB,需要提前准备,请查看[如何拥有 MongoDB 指南](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85) -配置数据库信息,查看[MongoDB 配置](./数据库)。 +配置数据库信息,查看[MongoDB 配置](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93)。 #### 2、安装依赖 diff --git a/README_EN.md b/README_EN.md index aa0624ba..2c3d832d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -145,7 +145,7 @@ npm run local #### 1.Configure Database -> The project uses MongoDB: [MongoDB Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85) +> The project uses MongoDB: [MongoDB Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93) Configure the database, check MongoDB configuration. diff --git a/docker-compose.yaml b/docker-compose.yaml index 33e37dbf..6f5666b7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,7 +15,7 @@ services: - xiaoju-survey xiaoju-survey: - image: "xiaojusurvey/xiaoju-survey:1.1.6-slim" # 最新版本:https://hub.docker.com/r/xiaojusurvey/xiaoju-survey/tags + image: "xiaojusurvey/xiaoju-survey:1.2.0-slim" # 最新版本:https://hub.docker.com/r/xiaojusurvey/xiaoju-survey/tags container_name: xiaoju-survey restart: always ports: diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 30e5d7fc..529d57e5 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -52,6 +52,9 @@ http { proxy_pass http://127.0.0.1:3000; } + location /exportfile { + proxy_pass http://127.0.0.1:3000; + } # 静态文件的默认存储文件夹 # 文件夹的配置在 server/src/modules/file/config/index.ts SERVER_LOCAL_CONFIG.FILE_KEY_PREFIX location /userUpload { diff --git a/server/.env b/server/.env index 3a0589b4..ae1549d8 100644 --- a/server/.env +++ b/server/.env @@ -1,7 +1,13 @@ XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey -XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017 +XIAOJU_SURVEY_MONGO_URL= XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin +XIAOJU_SURVEY_REDIS_HOST= +XIAOJU_SURVEY_REDIS_PORT= +XIAOJU_SURVEY_REDIS_USERNAME= +XIAOJU_SURVEY_REDIS_PASSWORD= +XIAOJU_SURVEY_REDIS_DB= + XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa diff --git a/server/.env.development b/server/.env.development index e69de29b..488910a2 100644 --- a/server/.env.development +++ b/server/.env.development @@ -0,0 +1,3 @@ +XIAOJU_SURVEY_REPORT=true +XIAOJU_SURVEY_MONGO_URL=mongodb://127.0.0.1:27017 + diff --git a/server/.gitignore b/server/.gitignore index 574e9bb2..93515c51 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -13,6 +13,7 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +yarn.lock # OS .DS_Store @@ -37,4 +38,6 @@ lerna-debug.log* !.vscode/launch.json !.vscode/extensions.json -tmp \ No newline at end of file +tmp +exportfile +userUpload \ No newline at end of file diff --git a/server/package.json b/server/package.json index b4acf6d2..3ef50f27 100644 --- a/server/package.json +++ b/server/package.json @@ -22,15 +22,17 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.4.4", "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.1", "ali-oss": "^6.20.0", - "cheerio": "^1.0.0-rc.12", + "cheerio": "1.0.0-rc.12", "crypto-js": "^4.2.0", "dotenv": "^16.3.2", "fs-extra": "^11.2.0", + "ioredis": "^5.4.1", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", @@ -41,7 +43,9 @@ "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "node-forge": "^1.3.1", + "node-xlsx": "^0.24.0", "qiniu": "^7.11.1", + "redlock": "^5.0.0-beta.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "svg-captcha": "^1.4.0", @@ -70,8 +74,9 @@ "jest": "^29.5.0", "mongodb-memory-server": "^9.1.4", "prettier": "^3.0.0", + "redis-memory-server": "^0.11.0", "source-map-support": "^0.5.21", - "supertest": "^6.3.3", + "supertest": "^7.0.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.1", diff --git a/server/scripts/run-local.ts b/server/scripts/run-local.ts index 963d3bea..ec5c6974 100644 --- a/server/scripts/run-local.ts +++ b/server/scripts/run-local.ts @@ -1,5 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server'; import { spawn } from 'child_process'; +// import { RedisMemoryServer } from 'redis-memory-server'; async function startServerAndRunScript() { // 启动 MongoDB 内存服务器 @@ -8,12 +9,19 @@ async function startServerAndRunScript() { console.log('MongoDB Memory Server started:', mongoUri); + // const redisServer = new RedisMemoryServer(); + // const redisHost = await redisServer.getHost(); + // const redisPort = await redisServer.getPort(); + // 通过 spawn 运行另一个脚本,并传递 MongoDB 连接 URL 作为环境变量 const tsnode = spawn( 'cross-env', [ `XIAOJU_SURVEY_MONGO_URL=${mongoUri}`, + // `XIAOJU_SURVEY_REDIS_HOST=${redisHost}`, + // `XIAOJU_SURVEY_REDIS_PORT=${redisPort}`, 'NODE_ENV=development', + 'SERVER_ENV=local', 'npm', 'run', 'start:dev', @@ -31,9 +39,10 @@ async function startServerAndRunScript() { console.error(data); }); - tsnode.on('close', (code) => { + tsnode.on('close', async (code) => { console.log(`Nodemon process exited with code ${code}`); - mongod.stop(); // 停止 MongoDB 内存服务器 + await mongod.stop(); // 停止 MongoDB 内存服务器 + // await redisServer.stop(); }); } diff --git a/server/scripts/run-report.ts b/server/scripts/run-report.ts new file mode 100644 index 00000000..72a74857 --- /dev/null +++ b/server/scripts/run-report.ts @@ -0,0 +1,64 @@ +import fs, { promises as fsa } from 'fs-extra'; +import path from 'path'; +import fetch from 'node-fetch'; + +interface PackageJson { + type?: string; + name?: string; + version?: string; + description?: string; + id?: string; + msg?: string; +} + +const getId = () => { + const id = new Date().getTime().toString(); + process.env.XIAOJU_SURVEY_REPORT_ID = id; + + return id; +}; + +const readData = async (directory: string): Promise => { + const packageJsonPath = path.join(directory, 'package.json'); + const id = process.env.XIAOJU_SURVEY_REPORT_ID || getId(); + try { + if (!fs.existsSync(directory)) { + return { + type: 'server', + name: '', + version: '', + description: '', + id, + msg: '文件不存在', + }; + } + const data = await fsa.readFile(packageJsonPath, 'utf8').catch((e) => e); + const { name, version, description } = JSON.parse(data) as PackageJson; + return { type: 'server', name, version, description, id }; + } catch (error) { + return error; + } +}; + +(async (): Promise => { + if ( + process.env.NODE_ENV === 'development' && + !process.env.XIAOJU_SURVEY_REPORT + ) { + return; + } + + const res = await readData(path.join(process.cwd())); + + // 上报 + fetch('https://xiaojusurveysrc.didi.cn/reportSourceData', { + method: 'POST', + headers: { + Accept: 'application/json, */*', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(res), + }).catch((e) => { + console.log(99999, e); + }); +})(); diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 416c70b7..100eabed 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -36,16 +36,21 @@ import { MessagePushingLog } from './models/messagePushingLog.entity'; import { WorkspaceMember } from './models/workspaceMember.entity'; import { Workspace } from './models/workspace.entity'; import { Collaborator } from './models/collaborator.entity'; +import { DownloadTask } from './models/downloadTask.entity'; +import { Session } from './models/session.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 { PluginManager } from './securityPlugin/pluginManager'; import { Logger } from './logger'; @Module({ imports: [ - ConfigModule.forRoot({}), + ConfigModule.forRoot({ + envFilePath: `.env.${process.env.NODE_ENV}`, // 根据 NODE_ENV 动态加载对应的 .env 文件 + isGlobal: true, // 使配置模块在应用的任何地方可用 + }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -82,6 +87,8 @@ import { Logger } from './logger'; Workspace, WorkspaceMember, Collaborator, + DownloadTask, + Session, ], }; }, @@ -116,7 +123,7 @@ import { Logger } from './logger'; export class AppModule { constructor( private readonly configService: ConfigService, - private readonly pluginManager: XiaojuSurveyPluginManager, + private readonly pluginManager: PluginManager, ) {} configure(consumer: MiddlewareConsumer) { consumer.apply(LogRequestMiddleware).forRoutes('*'); diff --git a/server/src/enums/downloadTaskStatus.ts b/server/src/enums/downloadTaskStatus.ts new file mode 100644 index 00000000..1ea35832 --- /dev/null +++ b/server/src/enums/downloadTaskStatus.ts @@ -0,0 +1,6 @@ +export enum DOWNLOAD_TASK_STATUS { + WAITING = 'waiting', // 排队中 + COMPUTING = 'computing', // 计算中 + SUCCEED = 'succeed', // 导出成功 + FAILED = 'failed', // 导出失败 +} diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index c8979b4c..e2285e93 100644 --- a/server/src/enums/exceptionCode.ts +++ b/server/src/enums/exceptionCode.ts @@ -12,6 +12,7 @@ export enum EXCEPTION_CODE { SURVEY_TYPE_ERROR = 3003, // 问卷类型错误 SURVEY_NOT_FOUND = 3004, // 问卷不存在 SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容 + SURVEY_SAVE_CONFLICT = 3006, // 问卷冲突 CAPTCHA_INCORRECT = 4001, // 验证码不正确 WHITELIST_ERROR = 4002, // 白名单校验错误 diff --git a/server/src/enums/index.ts b/server/src/enums/index.ts index 0bebf793..06d4b0ba 100644 --- a/server/src/enums/index.ts +++ b/server/src/enums/index.ts @@ -2,15 +2,14 @@ export enum RECORD_STATUS { NEW = 'new', // 新建 | 未发布 PUBLISHED = 'published', // 发布 - CLOSE = 'close', // 关闭 + EDITING = 'editing', // 编辑 + FINISHED = 'finished', // 已结束 + REMOVED = 'removed', } export const enum RECORD_SUB_STATUS { DEFAULT = '', // 默认 - EDITING = 'editing', // 编辑 PAUSING = 'pausing', // 暂停 - REMOVED = 'removed', // 删除 - FORCE_REMOVED = 'forceRemoved', // 从回收站删除 } // 历史类型 diff --git a/server/src/enums/surveySessionStatus.ts b/server/src/enums/surveySessionStatus.ts new file mode 100644 index 00000000..8f644be1 --- /dev/null +++ b/server/src/enums/surveySessionStatus.ts @@ -0,0 +1,4 @@ +export enum SESSION_STATUS { + ACTIVATED = 'activated', + DEACTIVATED = 'deactivated', +} diff --git a/server/src/guards/__test/session.guard.spec.ts b/server/src/guards/__test/session.guard.spec.ts new file mode 100644 index 00000000..1904fc22 --- /dev/null +++ b/server/src/guards/__test/session.guard.spec.ts @@ -0,0 +1,68 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SessionService } from 'src/modules/survey/services/session.service'; +import { SessionGuard } from '../session.guard'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; + +describe('SessionGuard', () => { + let sessionGuard: SessionGuard; + let reflector: Reflector; + let sessionService: SessionService; + + beforeEach(() => { + reflector = new Reflector(); + sessionService = { + findOne: jest.fn(), + } as unknown as SessionService; + sessionGuard = new SessionGuard(reflector, sessionService); + }); + + it('should return true when sessionId exists and sessionService returns sessionInfo', async () => { + const mockSessionId = '12345'; + const mockSessionInfo = { id: mockSessionId, name: 'test session' }; + + const context = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn().mockReturnValue({ + sessionId: mockSessionId, + }), + getHandler: jest.fn(), + } as unknown as ExecutionContext; + + jest.spyOn(reflector, 'get').mockReturnValue('sessionId'); + + jest + .spyOn(sessionService, 'findOne') + .mockResolvedValue(mockSessionInfo as any); + + const result = await sessionGuard.canActivate(context); + + const request = context.switchToHttp().getRequest(); + + expect(result).toBe(true); + expect(reflector.get).toHaveBeenCalledWith( + 'sessionId', + context.getHandler(), + ); + expect(sessionService.findOne).toHaveBeenCalledWith(mockSessionId); + expect(request.sessionInfo).toEqual(mockSessionInfo); + }); + + it('should throw NoPermissionException when sessionId is missing', async () => { + const context = { + switchToHttp: jest.fn().mockReturnThis(), + getRequest: jest.fn().mockReturnValue({}), + getHandler: jest.fn(), + } as unknown as ExecutionContext; + + jest.spyOn(reflector, 'get').mockReturnValue('sessionId'); + + await expect(sessionGuard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + expect(reflector.get).toHaveBeenCalledWith( + 'sessionId', + context.getHandler(), + ); + }); +}); diff --git a/server/src/guards/session.guard.ts b/server/src/guards/session.guard.ts new file mode 100644 index 00000000..9de14393 --- /dev/null +++ b/server/src/guards/session.guard.ts @@ -0,0 +1,30 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { get } from 'lodash'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; +import { SessionService } from 'src/modules/survey/services/session.service'; +@Injectable() +export class SessionGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly sessionService: SessionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const sessionIdKey = this.reflector.get( + 'sessionId', + context.getHandler(), + ); + + const sessionId = get(request, sessionIdKey); + + if (!sessionId) { + throw new NoPermissionException('没有权限'); + } + const sessionInfo = await this.sessionService.findOne(sessionId); + request.sessionInfo = sessionInfo; + request.surveyId = sessionInfo.surveyId; + return true; + } +} diff --git a/server/src/guards/survey.guard.ts b/server/src/guards/survey.guard.ts index de904edb..ab49526b 100644 --- a/server/src/guards/survey.guard.ts +++ b/server/src/guards/survey.guard.ts @@ -3,7 +3,6 @@ import { Reflector } from '@nestjs/core'; import { get } from 'lodash'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; - import { CollaboratorService } from 'src/modules/survey/services/collaborator.service'; import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; diff --git a/server/src/interfaces/survey.ts b/server/src/interfaces/survey.ts index 1e68afcc..c5834544 100644 --- a/server/src/interfaces/survey.ts +++ b/server/src/interfaces/survey.ts @@ -112,7 +112,7 @@ export enum MemberType { } export interface BaseConf { - begTime: string; + beginTime: string; endTime: string; answerBegTime: string; answerEndTime: string; @@ -135,6 +135,17 @@ export interface BaseConf { export interface SkinConf { skinColor: string; inputBgColor: string; + backgroundConf: { + color: string; + type: string; + image: string; + }; + contentConf: { + opacity: number; + }; + themeConf: { + color: string; + }; } export interface BottomConf { diff --git a/server/src/logger/index.ts b/server/src/logger/index.ts index f1892b71..c1fab057 100644 --- a/server/src/logger/index.ts +++ b/server/src/logger/index.ts @@ -1,15 +1,18 @@ import * as log4js from 'log4js'; import moment from 'moment'; -import { Request } from 'express'; +import { Injectable, Scope, Inject } from '@nestjs/common'; +import { CONTEXT, RequestContext } from '@nestjs/microservices'; + const log4jsLogger = log4js.getLogger(); +@Injectable({ scope: Scope.REQUEST }) export class Logger { private static inited = false; - constructor() {} + constructor(@Inject(CONTEXT) private readonly ctx: RequestContext) {} static init(config: { filename: string }) { - if (this.inited) { + if (Logger.inited) { return; } log4js.configure({ @@ -30,25 +33,26 @@ export class Logger { default: { appenders: ['app'], level: 'trace' }, }, }); + Logger.inited = true; } - _log(message, options: { dltag?: string; level: string; req?: Request }) { + _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 traceIdStr = options?.req?.['traceId'] - ? `traceid=${options?.req?.['traceId']}||` + const traceIdStr = this.ctx?.['traceId'] + ? `traceid=${this.ctx?.['traceId']}||` : ''; return log4jsLogger[level]( `[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`, ); } - info(message, options?: { dltag?: string; req?: Request }) { + info(message, options?: { dltag?: string }) { return this._log(message, { ...options, level: 'info' }); } - error(message, options: { dltag?: string; req?: Request }) { + error(message, options?: { dltag?: string }) { return this._log(message, { ...options, level: 'error' }); } } diff --git a/server/src/logger/util.ts b/server/src/logger/util.ts index ada62779..1f25d257 100644 --- a/server/src/logger/util.ts +++ b/server/src/logger/util.ts @@ -10,9 +10,9 @@ const getCountStr = () => { export const genTraceId = ({ ip }) => { // ip转16位 + 当前时间戳(毫秒级)+自增序列(1000开始自增到9000)+ 当前进程id的后5位 - ip = ip.replace('::ffff:', ''); + ip = ip.replace('::ffff:', '').replace('::1', ''); let ipArr; - if (ip.indexOf(':') > 0) { + if (ip.indexOf(':') >= 0) { ipArr = ip.split(':').map((segment) => { // 将IPv6每个段转为16位,并补0到长度为4 return parseInt(segment, 16).toString(16).padStart(4, '0'); @@ -20,7 +20,9 @@ export const genTraceId = ({ ip }) => { } else { ipArr = ip .split('.') - .map((item) => parseInt(item).toString(16).padStart(2, '0')); + .map((item) => + item ? parseInt(item).toString(16).padStart(2, '0') : '', + ); } return `${ipArr.join('')}${Date.now().toString()}${getCountStr()}${process.pid.toString().slice(-5)}`; diff --git a/server/src/main.ts b/server/src/main.ts index d764600e..95a0a907 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import 'scripts/run-report'; async function bootstrap() { const PORT = process.env.PORT || 3000; diff --git a/server/src/middlewares/logRequest.middleware.ts b/server/src/middlewares/logRequest.middleware.ts index ade62496..752ab9be 100644 --- a/server/src/middlewares/logRequest.middleware.ts +++ b/server/src/middlewares/logRequest.middleware.ts @@ -1,4 +1,3 @@ -// logger.middleware.ts import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import { Logger } from '../logger/index'; // 替换为你实际的logger路径 @@ -20,7 +19,6 @@ export class LogRequestMiddleware implements NestMiddleware { `method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`, { dltag: 'request_in', - req, }, ); @@ -30,7 +28,6 @@ export class LogRequestMiddleware implements NestMiddleware { `status=${res.statusCode.toString()}||duration=${duration}ms`, { dltag: 'request_out', - req, }, ); }); diff --git a/server/src/models/base.entity.ts b/server/src/models/base.entity.ts index 627bec97..43216835 100644 --- a/server/src/models/base.entity.ts +++ b/server/src/models/base.entity.ts @@ -1,53 +1,13 @@ -import { Column, ObjectIdColumn, BeforeInsert, BeforeUpdate } from 'typeorm'; +import { ObjectIdColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { ObjectId } from 'mongodb'; -import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums'; export class BaseEntity { @ObjectIdColumn() _id: ObjectId; - @Column() - curStatus: { - status: RECORD_STATUS; - date: number; - }; + @CreateDateColumn({ type: 'timestamp', precision: 3 }) + createdAt: Date; - @Column() - subStatus: { - status: RECORD_SUB_STATUS; - date: number; - }; - - @Column() - statusList: Array<{ - status: RECORD_STATUS | RECORD_SUB_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]; - } - if (!this.subStatus) { - const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now }; - this.subStatus = subStatus; - } - this.createDate = now; - this.updateDate = now; - } - - @BeforeUpdate() - onUpdate() { - this.updateDate = Date.now(); - } + @UpdateDateColumn({ type: 'timestamp', precision: 3 }) + updatedAt: Date; } diff --git a/server/src/models/captcha.entity.ts b/server/src/models/captcha.entity.ts index 55e1c45d..6bebba66 100644 --- a/server/src/models/captcha.entity.ts +++ b/server/src/models/captcha.entity.ts @@ -5,8 +5,7 @@ import { BaseEntity } from './base.entity'; @Entity({ name: 'captcha' }) export class Captcha extends BaseEntity { @Index({ - expireAfterSeconds: - new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, + expireAfterSeconds: 3600, }) @ObjectIdColumn() _id: ObjectId; diff --git a/server/src/models/clientEncrypt.entity.ts b/server/src/models/clientEncrypt.entity.ts index eaaebdbc..5f952afe 100644 --- a/server/src/models/clientEncrypt.entity.ts +++ b/server/src/models/clientEncrypt.entity.ts @@ -6,8 +6,7 @@ import { BaseEntity } from './base.entity'; @Entity({ name: 'clientEncrypt' }) export class ClientEncrypt extends BaseEntity { @Index({ - expireAfterSeconds: - new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000, + expireAfterSeconds: 3600, }) @ObjectIdColumn() _id: ObjectId; diff --git a/server/src/models/collaborator.entity.ts b/server/src/models/collaborator.entity.ts index 9e17f02b..2d4e35e0 100644 --- a/server/src/models/collaborator.entity.ts +++ b/server/src/models/collaborator.entity.ts @@ -11,4 +11,16 @@ export class Collaborator extends BaseEntity { @Column('jsonb') permissions: Array; + + @Column() + creator: string; + + @Column() + creatorId: string; + + @Column() + operator: string; + + @Column() + operatorId: string; } diff --git a/server/src/models/downloadTask.entity.ts b/server/src/models/downloadTask.entity.ts new file mode 100644 index 00000000..a65bcc12 --- /dev/null +++ b/server/src/models/downloadTask.entity.ts @@ -0,0 +1,48 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; +import { DOWNLOAD_TASK_STATUS } from 'src/enums/downloadTaskStatus'; + +@Entity({ name: 'downloadTask' }) +export class DownloadTask extends BaseEntity { + @Column() + surveyId: string; + + @Column() + surveyPath: string; + + // 文件路径 + @Column() + url: string; + + // 文件key + @Column() + fileKey: string; + + // 任务创建人 + @Column() + creatorId: string; + + // 任务创建人 + @Column() + creator: string; + + // 文件名 + @Column() + filename: string; + + // 文件大小 + @Column() + fileSize: string; + + @Column() + params: string; + + @Column() + isDeleted: boolean; + + @Column() + deletedAt: Date; + + @Column() + status: DOWNLOAD_TASK_STATUS; +} diff --git a/server/src/models/messagePushingTask.entity.ts b/server/src/models/messagePushingTask.entity.ts index 90aad278..f2f66cbd 100644 --- a/server/src/models/messagePushingTask.entity.ts +++ b/server/src/models/messagePushingTask.entity.ts @@ -27,4 +27,10 @@ export class MessagePushingTask extends BaseEntity { @Column() ownerId: string; + + @Column() + isDeleted: boolean; + + @Column() + deletedAt: Date; } diff --git a/server/src/models/responseSchema.entity.ts b/server/src/models/responseSchema.entity.ts index 60ee6fe8..fd3cd8e4 100644 --- a/server/src/models/responseSchema.entity.ts +++ b/server/src/models/responseSchema.entity.ts @@ -1,6 +1,7 @@ import { Entity, Column } from 'typeorm'; import { SurveySchemaInterface } from '../interfaces/survey'; import { BaseEntity } from './base.entity'; +import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums'; @Entity({ name: 'surveyPublish' }) export class ResponseSchema extends BaseEntity { @@ -15,4 +16,19 @@ export class ResponseSchema extends BaseEntity { @Column() pageId: string; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + subStatus: { + status: RECORD_SUB_STATUS; + date: number; + }; + + @Column() + isDeleted: boolean; } diff --git a/server/src/models/session.entity.ts b/server/src/models/session.entity.ts new file mode 100644 index 00000000..6f67e92b --- /dev/null +++ b/server/src/models/session.entity.ts @@ -0,0 +1,22 @@ +import { Entity, Column, Index, ObjectIdColumn } from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { BaseEntity } from './base.entity'; +import { SESSION_STATUS } from 'src/enums/surveySessionStatus'; + +@Entity({ name: 'session' }) +export class Session extends BaseEntity { + @Index({ + expireAfterSeconds: 3600, + }) + @ObjectIdColumn() + _id: ObjectId; + + @Column() + surveyId: string; + + @Column() + userId: string; + + @Column() + status: SESSION_STATUS; +} diff --git a/server/src/models/surveyHistory.entity.ts b/server/src/models/surveyHistory.entity.ts index 5d7650e4..4d573c4b 100644 --- a/server/src/models/surveyHistory.entity.ts +++ b/server/src/models/surveyHistory.entity.ts @@ -19,4 +19,7 @@ export class SurveyHistory extends BaseEntity { username: string; _id: string; }; + + @Column('string') + sessionId: string; } diff --git a/server/src/models/surveyMeta.entity.ts b/server/src/models/surveyMeta.entity.ts index b7543b9b..3e303470 100644 --- a/server/src/models/surveyMeta.entity.ts +++ b/server/src/models/surveyMeta.entity.ts @@ -1,5 +1,6 @@ -import { Entity, Column } from 'typeorm'; +import { Entity, Column, BeforeInsert } from 'typeorm'; import { BaseEntity } from './base.entity'; +import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums'; @Entity({ name: 'surveyMeta' }) export class SurveyMeta extends BaseEntity { @@ -18,6 +19,9 @@ export class SurveyMeta extends BaseEntity { @Column() creator: string; + @Column() + creatorId: string; + @Column() owner: string; @@ -32,4 +36,48 @@ export class SurveyMeta extends BaseEntity { @Column() workspaceId: string; + + @Column() + curStatus: { + status: RECORD_STATUS; + date: number; + }; + + @Column() + subStatus: { + status: RECORD_SUB_STATUS; + date: number; + }; + + @Column() + statusList: Array<{ + status: RECORD_STATUS | RECORD_SUB_STATUS; + date: number; + }>; + + @Column() + operator: string; + + @Column() + operatorId: string; + + @Column() + isDeleted: boolean; + + @Column() + deletedAt: Date; + + @BeforeInsert() + initDefaultInfo() { + const now = Date.now(); + if (!this.curStatus) { + const curStatus = { status: RECORD_STATUS.NEW, date: now }; + this.curStatus = curStatus; + this.statusList = [curStatus]; + } + if (!this.subStatus) { + const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now }; + this.subStatus = subStatus; + } + } } diff --git a/server/src/models/surveyResponse.entity.ts b/server/src/models/surveyResponse.entity.ts index fce0bfd1..c1e5db4b 100644 --- a/server/src/models/surveyResponse.entity.ts +++ b/server/src/models/surveyResponse.entity.ts @@ -27,11 +27,11 @@ export class SurveyResponse extends BaseEntity { @BeforeInsert() async onDataInsert() { - return await pluginManager.triggerHook('beforeResponseDataCreate', this); + return await pluginManager.triggerHook('encryptResponseData', this); } @AfterLoad() async onDataLoaded() { - return await pluginManager.triggerHook('afterResponseDataReaded', this); + return await pluginManager.triggerHook('decryptResponseData', this); } } diff --git a/server/src/models/workspace.entity.ts b/server/src/models/workspace.entity.ts index a918d607..8afb38d0 100644 --- a/server/src/models/workspace.entity.ts +++ b/server/src/models/workspace.entity.ts @@ -3,12 +3,33 @@ import { BaseEntity } from './base.entity'; @Entity({ name: 'workspace' }) export class Workspace extends BaseEntity { + @Column() + creatorId: string; + + @Column() + creator: string; + @Column() ownerId: string; + @Column() + owner: string; + @Column() name: string; @Column() description: string; + + @Column() + operator: string; + + @Column() + operatorId: string; + + @Column() + isDeleted: boolean; + + @Column() + deletedAt: Date; } diff --git a/server/src/models/workspaceMember.entity.ts b/server/src/models/workspaceMember.entity.ts index 72a6d186..3117138a 100644 --- a/server/src/models/workspaceMember.entity.ts +++ b/server/src/models/workspaceMember.entity.ts @@ -11,4 +11,16 @@ export class WorkspaceMember extends BaseEntity { @Column() role: string; + + @Column() + creator: string; + + @Column() + creatorId: string; + + @Column() + operator: string; + + @Column() + operatorId: string; } diff --git a/server/src/modules/auth/__test/user.service.spec.ts b/server/src/modules/auth/__test/user.service.spec.ts index afb16121..1a9cc4b9 100644 --- a/server/src/modules/auth/__test/user.service.spec.ts +++ b/server/src/modules/auth/__test/user.service.spec.ts @@ -5,7 +5,6 @@ import { UserService } from '../services/user.service'; import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { hash256 } from 'src/utils/hash256'; -import { RECORD_SUB_STATUS } from 'src/enums'; import { ObjectId } from 'mongodb'; describe('UserService', () => { @@ -145,7 +144,6 @@ describe('UserService', () => { expect(userRepository.findOne).toHaveBeenCalledWith({ where: { username: username, - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); expect(user).toEqual(userInfo); @@ -163,7 +161,6 @@ describe('UserService', () => { expect(findOneSpy).toHaveBeenCalledWith({ where: { username: username, - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); expect(user).toBe(null); @@ -184,7 +181,6 @@ describe('UserService', () => { expect(userRepository.findOne).toHaveBeenCalledWith({ where: { _id: new ObjectId(id), - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); expect(user).toEqual(userInfo); @@ -202,7 +198,6 @@ describe('UserService', () => { expect(findOneSpy).toHaveBeenCalledWith({ where: { _id: new ObjectId(id), - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); expect(user).toBe(null); @@ -228,7 +223,6 @@ describe('UserService', () => { expect(userRepository.find).toHaveBeenCalledWith({ where: { username: new RegExp(username), - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, skip: 0, take: 10, @@ -263,7 +257,6 @@ describe('UserService', () => { _id: { $in: idList.map((id) => new ObjectId(id)), }, - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, select: ['_id', 'username', 'createDate'], }); diff --git a/server/src/modules/auth/controllers/auth.controller.ts b/server/src/modules/auth/controllers/auth.controller.ts index 764e4d81..0c65388b 100644 --- a/server/src/modules/auth/controllers/auth.controller.ts +++ b/server/src/modules/auth/controllers/auth.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Post, Body, HttpCode, Get, Query } from '@nestjs/common'; +import { + Controller, + Post, + Body, + HttpCode, + Get, + Query, + Request, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserService } from '../services/user.service'; import { CaptchaService } from '../services/captcha.service'; @@ -187,7 +195,7 @@ export class AuthController { /** * 密码强度 */ - @Get('register/password/strength') + @Get('/password/strength') @HttpCode(200) async getPasswordStrength(@Query('password') password: string) { const numberReg = /[0-9]/.test(password); @@ -214,4 +222,28 @@ export class AuthController { data: 'Weak', }; } + + @Get('/verifyToken') + @HttpCode(200) + async verifyToken(@Request() req) { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return { + code: 200, + data: false, + }; + } + try { + await this.authService.verifyToken(token); + return { + code: 200, + data: true, + }; + } catch (error) { + return { + code: 200, + data: false, + }; + } + } } diff --git a/server/src/modules/auth/controllers/user.controller.ts b/server/src/modules/auth/controllers/user.controller.ts index c7e74359..3757a209 100644 --- a/server/src/modules/auth/controllers/user.controller.ts +++ b/server/src/modules/auth/controllers/user.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Query, HttpCode, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + Request, +} from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { Authentication } from 'src/guards/authentication.guard'; @@ -43,4 +50,16 @@ export class UserController { }), }; } + + @UseGuards(Authentication) + @Get('/getUserInfo') + async getUserInfo(@Request() req) { + return { + code: 200, + data: { + userId: req.user._id.toString(), + username: req.user.username, + }, + }; + } } diff --git a/server/src/modules/auth/services/user.service.ts b/server/src/modules/auth/services/user.service.ts index ecbe8271..0cbea97e 100644 --- a/server/src/modules/auth/services/user.service.ts +++ b/server/src/modules/auth/services/user.service.ts @@ -5,7 +5,6 @@ import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { hash256 } from 'src/utils/hash256'; -import { RECORD_SUB_STATUS } from 'src/enums'; import { ObjectId } from 'mongodb'; @Injectable() @@ -53,9 +52,6 @@ export class UserService { const user = await this.userRepository.findOne({ where: { username: username, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, }); @@ -66,9 +62,6 @@ export class UserService { const user = await this.userRepository.findOne({ where: { _id: new ObjectId(id), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, }); @@ -79,13 +72,10 @@ export class UserService { const list = await this.userRepository.find({ where: { username: new RegExp(username), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, skip, take, - select: ['_id', 'username', 'createDate'], + select: ['_id', 'username', 'createdAt'], }); return list; } @@ -96,11 +86,8 @@ export class UserService { _id: { $in: idList.map((item) => new ObjectId(item)), }, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, - select: ['_id', 'username', 'createDate'], + select: ['_id', 'username', 'createdAt'], }); return list; } diff --git a/server/src/modules/file/services/file.service.ts b/server/src/modules/file/services/file.service.ts index fb89ae1b..55b13439 100644 --- a/server/src/modules/file/services/file.service.ts +++ b/server/src/modules/file/services/file.service.ts @@ -14,13 +14,18 @@ export class FileService { configKey, file, pathPrefix, + filename, }: { configKey: string; file: Express.Multer.File; pathPrefix: string; + filename?: string; }) { const handler = this.getHandler(configKey); - const { key } = await handler.upload(file, { pathPrefix }); + const { key } = await handler.upload(file, { + pathPrefix, + filename, + }); const url = await handler.getUrl(key); return { key, diff --git a/server/src/modules/file/services/uploadHandlers/local.handler.ts b/server/src/modules/file/services/uploadHandlers/local.handler.ts index 122e7dcb..fb4c0d36 100644 --- a/server/src/modules/file/services/uploadHandlers/local.handler.ts +++ b/server/src/modules/file/services/uploadHandlers/local.handler.ts @@ -1,4 +1,4 @@ -import { join, dirname } from 'path'; +import { join, dirname, sep } from 'path'; import fse from 'fs-extra'; import { createWriteStream } from 'fs'; import { FileUploadHandler } from './uploadHandler.interface'; @@ -12,13 +12,20 @@ export class LocalHandler implements FileUploadHandler { async upload( file: Express.Multer.File, - options?: { pathPrefix?: string }, + options?: { pathPrefix?: string; filename?: string }, ): Promise<{ key: string }> { - const filename = await generateUniqueFilename(file.originalname); + let filename; + if (options?.filename) { + filename = file.filename; + } else { + filename = await generateUniqueFilename(file.originalname); + } const filePath = join( options?.pathPrefix ? options?.pathPrefix : '', filename, - ); + ) + .split(sep) + .join('/'); const physicalPath = join(this.physicalRootPath, filePath); await fse.mkdir(dirname(physicalPath), { recursive: true }); const writeStream = createWriteStream(physicalPath); @@ -35,6 +42,10 @@ export class LocalHandler implements FileUploadHandler { } getUrl(key: string): string { + if (process.env.SERVER_ENV === 'local') { + const port = process.env.PORT || 3000; + return `http://localhost:${port}/${key}`; + } return `/${key}`; } } diff --git a/server/src/modules/message/__test/messagePushingTask.service.spec.ts b/server/src/modules/message/__test/messagePushingTask.service.spec.ts index bbb8e20a..13acb78b 100644 --- a/server/src/modules/message/__test/messagePushingTask.service.spec.ts +++ b/server/src/modules/message/__test/messagePushingTask.service.spec.ts @@ -10,7 +10,6 @@ import { MessagePushingLogService } from '../services/messagePushingLog.service' import { CreateMessagePushingTaskDto } from '../dto/createMessagePushingTask.dto'; import { UpdateMessagePushingTaskDto } from '../dto/updateMessagePushingTask.dto'; -import { RECORD_SUB_STATUS } from 'src/enums'; import { MESSAGE_PUSHING_TYPE } from 'src/enums/messagePushing'; import { MESSAGE_PUSHING_HOOK } from 'src/enums/messagePushing'; import { MessagePushingTask } from 'src/models/messagePushingTask.entity'; @@ -121,7 +120,6 @@ describe('MessagePushingTaskService', () => { ownerId: mockOwnerId, surveys: { $all: [surveyId] }, triggerHook: hook, - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); }); @@ -146,7 +144,6 @@ describe('MessagePushingTaskService', () => { where: { ownerId: mockOwnerId, _id: new ObjectId(taskId), - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, }); }); @@ -161,10 +158,6 @@ describe('MessagePushingTaskService', () => { pushAddress: 'http://update.example.com', triggerHook: MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED, surveys: ['new survey id'], - subStatus: { - status: RECORD_SUB_STATUS.EDITING, - date: Date.now(), - }, }; const existingTask = new MessagePushingTask(); existingTask._id = new ObjectId(taskId); @@ -197,34 +190,26 @@ describe('MessagePushingTaskService', () => { const taskId = '65afc62904d5db18534c0f78'; const updateResult = { modifiedCount: 1 }; - const mockOwnerId = '66028642292c50f8b71a9eee'; + const mockOperatorId = '66028642292c50f8b71a9eee'; + const mockOperator = 'mockOperator'; jest.spyOn(repository, 'updateOne').mockResolvedValue(updateResult); const result = await service.remove({ id: taskId, - ownerId: mockOwnerId, + operatorId: mockOperatorId, + operator: mockOperator, }); expect(result).toEqual(updateResult); expect(repository.updateOne).toHaveBeenCalledWith( { - ownerId: mockOwnerId, + ownerId: mockOperatorId, _id: new ObjectId(taskId), - 'subStatus.status': { $ne: RECORD_SUB_STATUS.REMOVED }, }, { $set: { - subStatus: { - status: RECORD_SUB_STATUS.REMOVED, - date: expect.any(Number), - }, - }, - $push: { - statusList: { - status: RECORD_SUB_STATUS.REMOVED, - date: expect.any(Number), - }, + isDeleted: true, }, }, ); diff --git a/server/src/modules/message/controllers/messagePushingTask.controller.ts b/server/src/modules/message/controllers/messagePushingTask.controller.ts index a3938b69..1406a405 100644 --- a/server/src/modules/message/controllers/messagePushingTask.controller.ts +++ b/server/src/modules/message/controllers/messagePushingTask.controller.ts @@ -150,8 +150,9 @@ export class MessagePushingTaskController { async remove(@Request() req, @Param('id') id: string) { const userId = req.user._id; const res = await this.messagePushingTaskService.remove({ - ownerId: userId, id, + operator: req.user.username, + operatorId: userId, }); return { code: 200, diff --git a/server/src/modules/message/dto/messagePushingTask.dto.ts b/server/src/modules/message/dto/messagePushingTask.dto.ts index 5144b178..91bf8e96 100644 --- a/server/src/modules/message/dto/messagePushingTask.dto.ts +++ b/server/src/modules/message/dto/messagePushingTask.dto.ts @@ -4,7 +4,6 @@ import { MESSAGE_PUSHING_TYPE, MESSAGE_PUSHING_HOOK, } from 'src/enums/messagePushing'; -import { RECORD_STATUS } from 'src/enums'; export class MessagePushingTaskDto { @ApiProperty({ description: '任务id' }) @@ -27,12 +26,6 @@ export class MessagePushingTaskDto { @ApiProperty({ description: '所有者' }) owner: string; - - @ApiProperty({ description: '任务状态', required: false }) - curStatus?: { - status: RECORD_STATUS; - date: number; - }; } export class CodeDto { diff --git a/server/src/modules/message/dto/updateMessagePushingTask.dto.ts b/server/src/modules/message/dto/updateMessagePushingTask.dto.ts index a551a51d..5560c6b4 100644 --- a/server/src/modules/message/dto/updateMessagePushingTask.dto.ts +++ b/server/src/modules/message/dto/updateMessagePushingTask.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums'; import { MESSAGE_PUSHING_TYPE, MESSAGE_PUSHING_HOOK, @@ -20,15 +19,4 @@ export class UpdateMessagePushingTaskDto { @ApiProperty({ description: '绑定的问卷id', required: false }) surveys?: string[]; - - @ApiProperty({ description: '任务状态', required: false }) - curStatus?: { - status: RECORD_STATUS; - date: number; - }; - @ApiProperty({ description: '任务子状态', required: false }) - subStatus?: { - status: RECORD_SUB_STATUS; - date: number; - }; } diff --git a/server/src/modules/message/services/messagePushingTask.service.ts b/server/src/modules/message/services/messagePushingTask.service.ts index 62a0fb0a..6a5afd41 100644 --- a/server/src/modules/message/services/messagePushingTask.service.ts +++ b/server/src/modules/message/services/messagePushingTask.service.ts @@ -6,7 +6,6 @@ import { MESSAGE_PUSHING_HOOK } from 'src/enums/messagePushing'; import { CreateMessagePushingTaskDto } from '../dto/createMessagePushingTask.dto'; import { UpdateMessagePushingTaskDto } from '../dto/updateMessagePushingTask.dto'; import { ObjectId } from 'mongodb'; -import { RECORD_SUB_STATUS } from 'src/enums'; import { MESSAGE_PUSHING_TYPE } from 'src/enums/messagePushing'; import { MessagePushingLogService } from './messagePushingLog.service'; import { httpPost } from 'src/utils/request'; @@ -44,8 +43,8 @@ export class MessagePushingTaskService { ownerId?: string; }): Promise { const where: Record = { - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }; if (surveyId) { @@ -75,8 +74,8 @@ export class MessagePushingTaskService { where: { ownerId, _id: new ObjectId(id), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }, }); @@ -103,25 +102,26 @@ export class MessagePushingTaskService { const updatedTask = Object.assign(existingTask, updateData); return await this.messagePushingTaskRepository.save(updatedTask); } - async remove({ id, ownerId }: { id: string; ownerId: string }) { - const subStatus = { - status: RECORD_SUB_STATUS.REMOVED, - date: Date.now(), - }; + + async remove({ + id, + operator, + operatorId, + }: { + id: string; + operator: string; + operatorId: string; + }) { return this.messagePushingTaskRepository.updateOne( { - ownerId, _id: new ObjectId(id), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, { $set: { - subStatus, - }, - $push: { - statusList: subStatus as never, + isDeleted: true, + operator, + operatorId, + deletedAt: new Date(), }, }, ); @@ -146,6 +146,9 @@ export class MessagePushingTaskService { $push: { surveys: surveyId as never, }, + $set: { + updatedAt: new Date(), + }, }, ); } diff --git a/server/src/modules/redis/redis.module.ts b/server/src/modules/redis/redis.module.ts new file mode 100644 index 00000000..c3b28359 --- /dev/null +++ b/server/src/modules/redis/redis.module.ts @@ -0,0 +1,9 @@ +// src/redis/redis.module.ts +import { Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/server/src/modules/redis/redis.service.ts b/server/src/modules/redis/redis.service.ts new file mode 100644 index 00000000..84f0227c --- /dev/null +++ b/server/src/modules/redis/redis.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import Redlock, { Lock } from 'redlock'; + +@Injectable() +export class RedisService { + private readonly redisClient: Redis; + private readonly redlock: Redlock; + + constructor() { + this.redisClient = new Redis({ + host: process.env.XIAOJU_SURVEY_REDIS_HOST, + port: parseInt(process.env.XIAOJU_SURVEY_REDIS_PORT), + password: process.env.XIAOJU_SURVEY_REDIS_PASSWORD || undefined, + username: process.env.XIAOJU_SURVEY_REDIS_USERNAME || undefined, + db: parseInt(process.env.XIAOJU_SURVEY_REDIS_DB) || 0, + }); + this.redlock = new Redlock([this.redisClient], { + retryCount: 10, + retryDelay: 200, // ms + retryJitter: 200, // ms + }); + } + + async lockResource(resource: string, ttl: number): Promise { + return this.redlock.acquire([resource], ttl); + } + + async unlockResource(lock: Lock): Promise { + await lock.release(); + } +} diff --git a/server/src/modules/survey/__test/collaborator.controller.spec.ts b/server/src/modules/survey/__test/collaborator.controller.spec.ts index 0119aaa3..db5e2700 100644 --- a/server/src/modules/survey/__test/collaborator.controller.spec.ts +++ b/server/src/modules/survey/__test/collaborator.controller.spec.ts @@ -191,7 +191,6 @@ describe('CollaboratorController', () => { describe('getSurveyCollaboratorList', () => { it('should return collaborator list', async () => { const query = { surveyId: 'surveyId' }; - const req = { user: { _id: 'userId' } }; const result = [ { _id: 'collaboratorId', userId: 'userId', username: '' }, ]; @@ -202,7 +201,7 @@ describe('CollaboratorController', () => { jest.spyOn(userService, 'getUserListByIds').mockResolvedValueOnce([]); - const response = await controller.getSurveyCollaboratorList(query, req); + const response = await controller.getSurveyCollaboratorList(query); expect(response).toEqual({ code: 200, @@ -214,11 +213,10 @@ describe('CollaboratorController', () => { const query: GetSurveyCollaboratorListDto = { surveyId: '', }; - const req = { user: { _id: 'userId' } }; - await expect( - controller.getSurveyCollaboratorList(query, req), - ).rejects.toThrow(HttpException); + await expect(controller.getSurveyCollaboratorList(query)).rejects.toThrow( + HttpException, + ); expect(logger.error).toHaveBeenCalledTimes(1); }); }); @@ -230,14 +228,13 @@ describe('CollaboratorController', () => { userId: 'userId', permissions: ['read'], }; - const req = { user: { _id: 'userId' } }; const result = { _id: 'userId', permissions: ['read'] }; jest .spyOn(collaboratorService, 'changeUserPermission') .mockResolvedValue(result); - const response = await controller.changeUserPermission(reqBody, req); + const response = await controller.changeUserPermission(reqBody); expect(response).toEqual({ code: 200, @@ -251,11 +248,10 @@ describe('CollaboratorController', () => { userId: '', permissions: ['surveyManage'], }; - const req = { user: { _id: 'userId' } }; - await expect( - controller.changeUserPermission(reqBody, req), - ).rejects.toThrow(HttpException); + await expect(controller.changeUserPermission(reqBody)).rejects.toThrow( + HttpException, + ); expect(logger.error).toHaveBeenCalledTimes(1); }); }); @@ -263,14 +259,13 @@ describe('CollaboratorController', () => { describe('deleteCollaborator', () => { it('should delete collaborator successfully', async () => { const query = { surveyId: 'surveyId', userId: 'userId' }; - const req = { user: { _id: 'userId' } }; const result = { acknowledged: true, deletedCount: 1 }; jest .spyOn(collaboratorService, 'deleteCollaborator') .mockResolvedValue(result); - const response = await controller.deleteCollaborator(query, req); + const response = await controller.deleteCollaborator(query); expect(response).toEqual({ code: 200, @@ -280,9 +275,8 @@ describe('CollaboratorController', () => { it('should throw an exception if validation fails', async () => { const query = { surveyId: '', userId: '' }; - const req = { user: { _id: 'userId' } }; - await expect(controller.deleteCollaborator(query, req)).rejects.toThrow( + await expect(controller.deleteCollaborator(query)).rejects.toThrow( HttpException, ); expect(logger.error).toHaveBeenCalledTimes(1); diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index 5b9b5d82..bdc4b425 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -8,7 +8,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service'; import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; import { Logger } from 'src/logger'; import { UserService } from 'src/modules/auth/services/user.service'; @@ -27,7 +27,7 @@ describe('DataStatisticController', () => { let controller: DataStatisticController; let dataStatisticService: DataStatisticService; let responseSchemaService: ResponseSchemaService; - let pluginManager: XiaojuSurveyPluginManager; + let pluginManager: PluginManager; let logger: Logger; beforeEach(async () => { @@ -70,9 +70,7 @@ describe('DataStatisticController', () => { responseSchemaService = module.get( ResponseSchemaService, ); - pluginManager = module.get( - XiaojuSurveyPluginManager, - ); + pluginManager = module.get(PluginManager); logger = module.get(Logger); pluginManager.registerPlugin( @@ -90,7 +88,7 @@ describe('DataStatisticController', () => { const mockRequest = { query: { surveyId, - isDesensitive: false, + isMasked: false, page: 1, pageSize: 10, }, @@ -123,7 +121,7 @@ describe('DataStatisticController', () => { .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, mockRequest); + const result = await controller.data(mockRequest.query); expect(result).toEqual({ code: 200, @@ -131,12 +129,12 @@ describe('DataStatisticController', () => { }); }); - it('should return data table with isDesensitive', async () => { + it('should return data table with isMasked', async () => { const surveyId = new ObjectId().toString(); const mockRequest = { query: { surveyId, - isDesensitive: true, + isMasked: true, page: 1, pageSize: 10, }, @@ -169,7 +167,7 @@ describe('DataStatisticController', () => { .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, mockRequest); + const result = await controller.data(mockRequest.query); expect(result).toEqual({ code: 200, @@ -187,9 +185,9 @@ describe('DataStatisticController', () => { }, }; - await expect( - controller.data(mockRequest.query, mockRequest), - ).rejects.toThrow(HttpException); + await expect(controller.data(mockRequest.query)).rejects.toThrow( + HttpException, + ); expect(logger.error).toHaveBeenCalledTimes(1); }); }); @@ -235,7 +233,7 @@ describe('DataStatisticController', () => { }, }, baseConf: { - begTime: '2024-05-31 20:31:36', + beginTime: '2024-05-31 20:31:36', endTime: '2034-05-31 20:31:36', language: 'chinese', showVoteProcess: 'allow', @@ -251,6 +249,8 @@ describe('DataStatisticController', () => { skinConf: { backgroundConf: { color: '#fff', + type: 'color', + image: '', }, themeConf: { color: '#ffa600', diff --git a/server/src/modules/survey/__test/dataStatistic.service.spec.ts b/server/src/modules/survey/__test/dataStatistic.service.spec.ts index bf7b69bd..b53bfa0d 100644 --- a/server/src/modules/survey/__test/dataStatistic.service.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.service.spec.ts @@ -11,7 +11,7 @@ import { cloneDeep } from 'lodash'; import { getRepositoryToken } from '@nestjs/typeorm'; import { RECORD_STATUS } from 'src/enums'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; describe('DataStatisticService', () => { @@ -34,9 +34,7 @@ describe('DataStatisticService', () => { surveyResponseRepository = module.get>( getRepositoryToken(SurveyResponse), ); - const manager = module.get( - XiaojuSurveyPluginManager, - ); + const manager = module.get(PluginManager); manager.registerPlugin( new ResponseSecurityPlugin('dataAesEncryptSecretKey'), ); @@ -204,7 +202,7 @@ describe('DataStatisticService', () => { }); }); - it('should return desensitive table data', async () => { + it('should return desensitized table data', async () => { const mockSchema = cloneDeep(mockSensitiveResponseSchema); const surveyResponseList: Array = [ { diff --git a/server/src/modules/survey/__test/downloadTask.controller.spec.ts b/server/src/modules/survey/__test/downloadTask.controller.spec.ts new file mode 100644 index 00000000..48cd4f94 --- /dev/null +++ b/server/src/modules/survey/__test/downloadTask.controller.spec.ts @@ -0,0 +1,259 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ObjectId } from 'mongodb'; +import { DownloadTaskController } from '../controllers/downloadTask.controller'; +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; +import { AuthService } from 'src/modules/auth/services/auth.service'; +import { DownloadTaskService } from '../services/downloadTask.service'; +import { CollaboratorService } from '../services/collaborator.service'; +import { SurveyMetaService } from '../services/surveyMeta.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; + +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; + +describe('DownloadTaskController', () => { + let controller: DownloadTaskController; + let responseSchemaService: ResponseSchemaService; + let downloadTaskService: DownloadTaskService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DownloadTaskController], + providers: [ + { + provide: ResponseSchemaService, + useValue: { + getResponseSchemaByPageId: jest.fn(), + }, + }, + { + provide: DownloadTaskService, + useValue: { + createDownloadTask: jest.fn(), + processDownloadTask: jest.fn(), + getDownloadTaskList: jest.fn(), + getDownloadTaskById: jest.fn(), + deleteDownloadTask: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, + { + provide: AuthService, + useClass: jest.fn().mockImplementation(() => ({ + varifytoken() { + return {}; + }, + })), + }, + { + provide: CollaboratorService, + useValue: {}, + }, + { + provide: SurveyMetaService, + useValue: {}, + }, + { + provide: WorkspaceMemberService, + useValue: {}, + }, + { + provide: Authentication, + useClass: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), + }, + { + provide: SurveyGuard, + useClass: jest.fn().mockImplementation(() => ({ + canActivate: () => true, + })), + }, + ], + }).compile(); + + controller = module.get(DownloadTaskController); + responseSchemaService = module.get( + ResponseSchemaService, + ); + downloadTaskService = module.get(DownloadTaskService); + }); + + describe('createTask', () => { + it('should create a download task successfully', async () => { + const mockReqBody = { + surveyId: new ObjectId().toString(), + isMasked: false, + }; + const mockReq = { user: { _id: 'mockUserId', username: 'mockUsername' } }; + const mockTaskId = 'mockTaskId'; + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') + .mockResolvedValue({} as any); + jest + .spyOn(downloadTaskService, 'createDownloadTask') + .mockResolvedValue(mockTaskId); + + const result = await controller.createTask(mockReqBody, mockReq); + + expect( + responseSchemaService.getResponseSchemaByPageId, + ).toHaveBeenCalledWith(mockReqBody.surveyId); + expect(downloadTaskService.createDownloadTask).toHaveBeenCalledWith({ + surveyId: mockReqBody.surveyId, + responseSchema: {}, + creatorId: mockReq.user._id.toString(), + creator: mockReq.user.username, + params: { isMasked: mockReqBody.isMasked }, + }); + expect(downloadTaskService.processDownloadTask).toHaveBeenCalledWith({ + taskId: mockTaskId, + }); + expect(result).toEqual({ code: 200, data: { taskId: mockTaskId } }); + }); + + it('should throw HttpException if validation fails', async () => { + const mockReqBody: any = { isMasked: false }; + const mockReq = { user: { _id: 'mockUserId', username: 'mockUsername' } }; + + await expect(controller.createTask(mockReqBody, mockReq)).rejects.toThrow( + HttpException, + ); + }); + }); + + describe('downloadList', () => { + it('should return the download task list', async () => { + const mockQueryInfo = { pageIndex: 1, pageSize: 10 }; + const mockReq = { user: { _id: 'mockUserId' } }; + const mockTaskList: any = { + total: 1, + list: [ + { + _id: 'mockTaskId', + curStatus: 'completed', + filename: 'mockFile.csv', + url: 'http://mock-url.com', + fileSize: 1024, + createDate: Date.now(), + }, + ], + }; + jest + .spyOn(downloadTaskService, 'getDownloadTaskList') + .mockResolvedValue(mockTaskList); + + const result = await controller.downloadList(mockQueryInfo, mockReq); + + expect(downloadTaskService.getDownloadTaskList).toHaveBeenCalledWith({ + creatorId: mockReq.user._id.toString(), + pageIndex: mockQueryInfo.pageIndex, + pageSize: mockQueryInfo.pageSize, + }); + expect(result.data.total).toEqual(mockTaskList.total); + expect(result.data.list[0].taskId).toEqual( + mockTaskList.list[0]._id.toString(), + ); + }); + + it('should throw HttpException if validation fails', async () => { + const mockQueryInfo: any = { pageIndex: 'invalid', pageSize: 10 }; + const mockReq = { user: { _id: 'mockUserId' } }; + + await expect( + controller.downloadList(mockQueryInfo, mockReq), + ).rejects.toThrow(HttpException); + }); + }); + + describe('getDownloadTask', () => { + it('should return a download task', async () => { + const mockQuery = { taskId: 'mockTaskId' }; + const mockReq = { user: { _id: 'mockUserId' } }; + const mockTaskInfo: any = { + _id: 'mockTaskId', + creatorId: 'mockUserId', + curStatus: 'completed', + }; + jest + .spyOn(downloadTaskService, 'getDownloadTaskById') + .mockResolvedValue(mockTaskInfo); + + const result = await controller.getDownloadTask(mockQuery, mockReq); + + expect(downloadTaskService.getDownloadTaskById).toHaveBeenCalledWith({ + taskId: mockQuery.taskId, + }); + expect(result.data.taskId).toEqual(mockTaskInfo._id.toString()); + }); + + it('should throw NoPermissionException if user has no permission', async () => { + const mockQuery = { taskId: 'mockTaskId' }; + const mockReq = { user: { _id: new ObjectId() } }; + const mockTaskInfo: any = { + _id: 'mockTaskId', + creatorId: 'mockUserId', + curStatus: 'completed', + }; + + jest + .spyOn(downloadTaskService, 'getDownloadTaskById') + .mockResolvedValue(mockTaskInfo); + + await expect( + controller.getDownloadTask(mockQuery, mockReq), + ).rejects.toThrow(new NoPermissionException('没有权限')); + }); + }); + + describe('deleteFileByName', () => { + it('should delete a download task successfully', async () => { + const mockBody = { taskId: 'mockTaskId' }; + const mockReq = { user: { _id: 'mockUserId' } }; + const mockTaskInfo: any = { + _id: new ObjectId(), + creatorId: 'mockUserId', + }; + const mockDelRes = { modifiedCount: 1 }; + + jest + .spyOn(downloadTaskService, 'getDownloadTaskById') + .mockResolvedValue(mockTaskInfo); + jest + .spyOn(downloadTaskService, 'deleteDownloadTask') + .mockResolvedValue(mockDelRes); + + const result = await controller.deleteFileByName(mockBody, mockReq); + + expect(downloadTaskService.deleteDownloadTask).toHaveBeenCalledWith({ + taskId: mockBody.taskId, + }); + expect(result).toEqual({ code: 200, data: true }); + }); + + it('should throw HttpException if task does not exist', async () => { + const mockBody = { taskId: 'mockTaskId' }; + const mockReq = { user: { _id: 'mockUserId' } }; + + jest + .spyOn(downloadTaskService, 'getDownloadTaskById') + .mockResolvedValue(null); + + await expect( + controller.deleteFileByName(mockBody, mockReq), + ).rejects.toThrow( + new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR), + ); + }); + }); +}); diff --git a/server/src/modules/survey/__test/downloadTask.service.spec.ts b/server/src/modules/survey/__test/downloadTask.service.spec.ts new file mode 100644 index 00000000..14fe70a8 --- /dev/null +++ b/server/src/modules/survey/__test/downloadTask.service.spec.ts @@ -0,0 +1,191 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DownloadTaskService } from '../services/downloadTask.service'; +import { MongoRepository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DownloadTask } from 'src/models/downloadTask.entity'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { ResponseSchemaService } from 'src/modules/surveyResponse/services/responseScheme.service'; +import { DataStatisticService } from '../services/dataStatistic.service'; +import { FileService } from 'src/modules/file/services/file.service'; +import { Logger } from 'src/logger'; +import { ObjectId } from 'mongodb'; +import { RECORD_STATUS } from 'src/enums'; + +describe('DownloadTaskService', () => { + let service: DownloadTaskService; + let downloadTaskRepository: MongoRepository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DownloadTaskService, + { + provide: getRepositoryToken(DownloadTask), + useClass: MongoRepository, + }, + { + provide: getRepositoryToken(SurveyResponse), + useClass: MongoRepository, + }, + { + provide: ResponseSchemaService, + useValue: { + getResponseSchemaByPageId: jest.fn(), + }, + }, + { + provide: DataStatisticService, + useValue: { + getDataTable: jest.fn(), + }, + }, + { + provide: FileService, + useValue: { + upload: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + info: jest.fn(), + error: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DownloadTaskService); + downloadTaskRepository = module.get>( + getRepositoryToken(DownloadTask), + ); + }); + + describe('createDownloadTask', () => { + it('should create and save a download task', async () => { + const mockTaskId = new ObjectId().toString(); + const mockDownloadTask = { _id: new ObjectId(mockTaskId) }; + const mockParams: any = { + surveyId: 'survey1', + responseSchema: { title: 'test-title', surveyPath: '/path' }, + creatorId: 'creator1', + creator: 'creatorName', + params: { isMasked: true }, + }; + + jest + .spyOn(downloadTaskRepository, 'create') + .mockReturnValue(mockDownloadTask as any); + jest + .spyOn(downloadTaskRepository, 'save') + .mockResolvedValue(mockDownloadTask as any); + + const result = await service.createDownloadTask(mockParams); + + expect(downloadTaskRepository.create).toHaveBeenCalledWith({ + surveyId: mockParams.surveyId, + surveyPath: mockParams.responseSchema.surveyPath, + fileSize: '计算中', + creatorId: mockParams.creatorId, + creator: mockParams.creator, + params: { + ...mockParams.params, + title: mockParams.responseSchema.title, + }, + filename: expect.any(String), + }); + expect(downloadTaskRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockTaskId); + }); + }); + + describe('getDownloadTaskList', () => { + it('should return task list and total count', async () => { + const mockCreatorId = 'creator1'; + const mockTasks = [{ _id: '1' }, { _id: '2' }]; + const mockTotal = 2; + + jest + .spyOn(downloadTaskRepository, 'findAndCount') + .mockResolvedValue([mockTasks as any, mockTotal]); + + const result = await service.getDownloadTaskList({ + creatorId: mockCreatorId, + pageIndex: 1, + pageSize: 10, + }); + + expect(downloadTaskRepository.findAndCount).toHaveBeenCalledWith({ + where: { + creatorId: mockCreatorId, + }, + take: 10, + skip: 0, + order: { createDate: -1 }, + }); + + expect(result).toEqual({ + total: mockTotal, + list: mockTasks, + }); + }); + }); + + describe('getDownloadTaskById', () => { + it('should return task by id', async () => { + const mockTaskId = new ObjectId().toString(); + const mockTask = { _id: new ObjectId(mockTaskId) }; + + jest + .spyOn(downloadTaskRepository, 'find') + .mockResolvedValue([mockTask as any]); + + const result = await service.getDownloadTaskById({ taskId: mockTaskId }); + + expect(downloadTaskRepository.find).toHaveBeenCalledWith({ + where: { _id: new ObjectId(mockTaskId) }, + }); + expect(result).toEqual(mockTask); + }); + + it('should return null if task is not found', async () => { + const mockTaskId = new ObjectId().toString(); + + jest.spyOn(downloadTaskRepository, 'find').mockResolvedValue([]); + + const result = await service.getDownloadTaskById({ taskId: mockTaskId }); + + expect(result).toBeNull(); + }); + }); + + describe('deleteDownloadTask', () => { + it('should update task status to REMOVED', async () => { + const mockTaskId = new ObjectId().toString(); + const mockUpdateResult = { matchedCount: 1 }; + + jest + .spyOn(downloadTaskRepository, 'updateOne') + .mockResolvedValue(mockUpdateResult as any); + + const result = await service.deleteDownloadTask({ taskId: mockTaskId }); + + expect(downloadTaskRepository.updateOne).toHaveBeenCalledWith( + { + _id: new ObjectId(mockTaskId), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + { + $set: { + curStatus: { + status: RECORD_STATUS.REMOVED, + date: expect.any(Number), + }, + }, + $push: { statusList: expect.any(Object) }, + }, + ); + expect(result).toEqual(mockUpdateResult); + }); + }); +}); diff --git a/server/src/modules/survey/__test/mockResponseSchema.ts b/server/src/modules/survey/__test/mockResponseSchema.ts index c0e44602..fbacaca7 100644 --- a/server/src/modules/survey/__test/mockResponseSchema.ts +++ b/server/src/modules/survey/__test/mockResponseSchema.ts @@ -21,8 +21,7 @@ export const mockSensitiveResponseSchema: ResponseSchema = { code: { bannerConf: { titleConfig: { - mainTitle: - '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', + mainTitle: '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', subTitle: '', }, bannerConfig: { @@ -32,7 +31,7 @@ export const mockSensitiveResponseSchema: ResponseSchema = { }, }, baseConf: { - begTime: '2024-03-14 14:54:41', + beginTime: '2024-03-14 14:54:41', endTime: '2034-03-14 14:54:41', language: 'chinese', tLimit: 0, @@ -44,6 +43,17 @@ export const mockSensitiveResponseSchema: ResponseSchema = { logoImageWidth: '60%', }, skinConf: { + backgroundConf: { + color: '#fff', + type: 'color', + image: '', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, skinColor: '#4a4c5b', inputBgColor: '#ffffff', }, @@ -284,7 +294,7 @@ export const mockSensitiveResponseSchema: ResponseSchema = { }, }, pageId: '65f29f3192862d6a9067ad1c', -} as ResponseSchema; +} as unknown as ResponseSchema; export const mockResponseSchema: ResponseSchema = { _id: new ObjectId('65b0d46e04d5db18534c0f7c'), @@ -303,19 +313,17 @@ export const mockResponseSchema: ResponseSchema = { code: { bannerConf: { titleConfig: { - mainTitle: - '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', + mainTitle: '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', subTitle: '', }, bannerConfig: { - bgImage: - 'http://10.190.55.101:3000/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp', + bgImage: 'http://10.190.55.101:3000/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp', videoLink: '', postImg: '', }, }, baseConf: { - begTime: '2024-01-23 21:59:05', + beginTime: '2024-01-23 21:59:05', endTime: '2034-01-23 21:59:05', language: 'chinese', tLimit: 0, @@ -327,6 +335,17 @@ export const mockResponseSchema: ResponseSchema = { logoImageWidth: '60%', }, skinConf: { + backgroundConf: { + color: '#fff', + type: 'color', + image: '', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, skinColor: '#4a4c5b', inputBgColor: '#ffffff', }, @@ -634,4 +653,4 @@ export const mockResponseSchema: ResponseSchema = { pageId: '65afc62904d5db18534c0f78', createDate: 1710340841289, updateDate: 1710340841289.0, -} as ResponseSchema; +} as unknown as ResponseSchema; diff --git a/server/src/modules/survey/__test/session.controller.spec.ts b/server/src/modules/survey/__test/session.controller.spec.ts new file mode 100644 index 00000000..46e94775 --- /dev/null +++ b/server/src/modules/survey/__test/session.controller.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SessionController } from '../controllers/session.controller'; +import { SessionService } from '../services/session.service'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SessionGuard } from 'src/guards/session.guard'; + +describe('SessionController', () => { + let controller: SessionController; + let sessionService: jest.Mocked; + let logger: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SessionController], + providers: [ + { + provide: SessionService, + useValue: { + create: jest.fn(), + updateSessionToEditing: jest.fn(), + }, + }, + { + provide: Logger, + useValue: { + error: jest.fn(), + }, + }, + ], + }) + .overrideGuard(Authentication) + .useValue({ canActivate: () => true }) + .overrideGuard(SurveyGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(SessionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SessionController); + sessionService = module.get>(SessionService); + logger = module.get>(Logger); + }); + + it('should create a session', async () => { + const reqBody = { surveyId: '123' }; + const req = { user: { _id: 'userId' } }; + const session: any = { _id: 'sessionId' }; + + sessionService.create.mockResolvedValue(session); + + const result = await controller.create(reqBody, req); + + expect(sessionService.create).toHaveBeenCalledWith({ + surveyId: '123', + userId: 'userId', + }); + expect(result).toEqual({ code: 200, data: { sessionId: 'sessionId' } }); + }); + + it('should throw an exception if validation fails', async () => { + const reqBody = { surveyId: null }; + const req = { user: { _id: 'userId' } }; + + try { + await controller.create(reqBody, req); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect(logger.error).toHaveBeenCalled(); + } + }); + + it('should seize a session', async () => { + const req = { + sessionInfo: { _id: 'sessionId', surveyId: 'surveyId' }, + }; + + await controller.seize(req); + + expect(sessionService.updateSessionToEditing).toHaveBeenCalledWith({ + sessionId: 'sessionId', + surveyId: 'surveyId', + }); + }); +}); diff --git a/server/src/modules/survey/__test/survey.controller.spec.ts b/server/src/modules/survey/__test/survey.controller.spec.ts index a23f56d8..d141508f 100644 --- a/server/src/modules/survey/__test/survey.controller.spec.ts +++ b/server/src/modules/survey/__test/survey.controller.spec.ts @@ -5,18 +5,22 @@ 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 { CounterService } from '../../surveyResponse/services/counter.service'; +import { SessionService } from '../services/session.service'; +import { UserService } from '../../auth/services/user.service'; import { ObjectId } from 'mongodb'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyConf } from 'src/models/surveyConf.entity'; -import { HttpException } from 'src/exceptions/httpException'; -import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { LoggerProvider } from 'src/logger/logger.provider'; +import { Logger } from 'src/logger'; 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('../services/session.service'); +jest.mock('../../surveyResponse/services/counter.service'); +jest.mock('../../auth/services/user.service'); jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); @@ -27,19 +31,36 @@ describe('SurveyController', () => { let surveyMetaService: SurveyMetaService; let surveyConfService: SurveyConfService; let responseSchemaService: ResponseSchemaService; - let contentSecurityService: ContentSecurityService; let surveyHistoryService: SurveyHistoryService; + let sessionService: SessionService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SurveyController], providers: [ SurveyMetaService, - SurveyConfService, + { + provide: SurveyConfService, + useValue: { + getSurveyConfBySurveyId: jest.fn(), + getSurveyContentByCode: jest.fn(), + createSurveyConf: jest.fn(), + saveSurveyConf: jest.fn(), + }, + }, ResponseSchemaService, ContentSecurityService, SurveyHistoryService, - LoggerProvider, + SessionService, + CounterService, + UserService, + { + provide: Logger, + useValue: { + error: jest.fn(), + info: jest.fn(), + }, + }, ], }).compile(); @@ -49,17 +70,14 @@ describe('SurveyController', () => { responseSchemaService = module.get( ResponseSchemaService, ); - contentSecurityService = module.get( - ContentSecurityService, - ); surveyHistoryService = module.get(SurveyHistoryService); + sessionService = module.get(SessionService); }); describe('getBannerData', () => { it('should return banner data', async () => { const result = await controller.getBannerData(); - expect(result.code).toBe(200); expect(result.data).toBeDefined(); }); @@ -71,33 +89,17 @@ describe('SurveyController', () => { 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); - }, - ); + jest.spyOn(surveyMetaService, 'createSurveyMeta').mockResolvedValue({ + _id: newId, + } as SurveyMeta); + + jest.spyOn(surveyConfService, 'createSurveyConf').mockResolvedValue({ + _id: new ObjectId(), + pageId: newId.toString(), + } as SurveyConf); const result = await controller.createSurvey(surveyInfo, { user: { username: 'testUser', _id: new ObjectId() }, @@ -126,19 +128,15 @@ describe('SurveyController', () => { createFrom: existsSurveyId.toString(), }; - jest - .spyOn(surveyMetaService, 'createSurveyMeta') - .mockImplementation(() => { - const result = { - _id: new ObjectId(), - } as SurveyMeta; - return Promise.resolve(result); - }); + jest.spyOn(surveyMetaService, 'createSurveyMeta').mockResolvedValue({ + _id: new ObjectId(), + } as SurveyMeta); const request = { user: { username: 'testUser', _id: new ObjectId() }, surveyMeta: existsSurveyMeta, - }; // 模拟请求对象,根据实际情况进行调整 + }; + const result = await controller.createSurvey(params, request); expect(result?.data?.id).toBeDefined(); }); @@ -159,6 +157,12 @@ describe('SurveyController', () => { jest .spyOn(surveyHistoryService, 'addHistory') .mockResolvedValue(undefined); + jest + .spyOn(sessionService, 'findLatestEditingOne') + .mockResolvedValue(null); + jest + .spyOn(sessionService, 'updateSessionToEditing') + .mockResolvedValue(undefined); const reqBody = { surveyId: surveyId.toString(), @@ -168,16 +172,31 @@ describe('SurveyController', () => { bannerConfig: {}, }, baseConf: { - begTime: '2024-01-23 21:59:05', + beginTime: '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' }, + skinConf: { + skinColor: '#4a4c5b', + inputBgColor: '#ffffff', + backgroundConf: { + color: '#fff', + type: 'color', + image: '', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, + }, submitConf: {}, dataConf: { dataList: [], }, }, + sessionId: 'mock-session-id', }; const result = await controller.updateConf(reqBody, { @@ -229,12 +248,10 @@ describe('SurveyController', () => { jest .spyOn(surveyConfService, 'getSurveyConfBySurveyId') - .mockResolvedValue( - Promise.resolve({ - _id: new ObjectId(), - pageId: surveyId.toString(), - } as SurveyConf), - ); + .mockResolvedValue({ + _id: new ObjectId(), + pageId: surveyId.toString(), + } as SurveyConf); const request = { user: { username: 'testUser', _id: new ObjectId() }, @@ -250,7 +267,7 @@ describe('SurveyController', () => { }); describe('publishSurvey', () => { - it('should publish a survey success', async () => { + it('should publish a survey successfully', async () => { const surveyId = new ObjectId(); const surveyMeta = { _id: surveyId, @@ -260,80 +277,24 @@ describe('SurveyController', () => { jest .spyOn(surveyConfService, 'getSurveyConfBySurveyId') - .mockResolvedValue( - Promise.resolve({ - _id: new ObjectId(), - pageId: surveyId.toString(), - } as SurveyConf), - ); + .mockResolvedValue({ + _id: new ObjectId(), + pageId: surveyId.toString(), + code: {}, + } 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); + .mockResolvedValue({ text: '' }); const result = await controller.publishSurvey( { surveyId: surveyId.toString() }, - { user: { username: 'testUser', _id: 'testUserId' }, surveyMeta }, - ); - - expect(result).toEqual({ - code: 200, - }); - }); - - it('should not publish a survey with forbidden content', async () => { - const surveyId = new ObjectId(); - const surveyMeta = { - _id: surveyId, - surveyType: 'normal', - owner: 'testUser', - } as SurveyMeta; - - jest - .spyOn(surveyConfService, 'getSurveyConfBySurveyId') - .mockResolvedValue( - Promise.resolve({ - _id: new ObjectId(), - pageId: surveyId.toString(), - } as SurveyConf), - ); - - jest - .spyOn(surveyConfService, 'getSurveyContentByCode') - .mockResolvedValue({ - text: '违禁词', - }); - - jest - .spyOn(contentSecurityService, 'isForbiddenContent') - .mockResolvedValue(true); - - await expect( - controller.publishSurvey( - { surveyId: surveyId.toString() }, - { user: { username: 'testUser', _id: 'testUserId' }, surveyMeta }, - ), - ).rejects.toThrow( - new HttpException( - '问卷存在非法关键字,不允许发布', - EXCEPTION_CODE.SURVEY_CONTENT_NOT_ALLOW, - ), + { + user: { username: 'testUser', _id: new ObjectId() }, + surveyMeta, + }, ); + expect(result.code).toBe(200); }); }); }); diff --git a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts index cffd5a76..e0a32417 100644 --- a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts +++ b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts @@ -66,7 +66,7 @@ describe('SurveyHistoryController', () => { it('should return history list when query is valid', async () => { const queryInfo = { surveyId: 'survey123', historyType: 'published' }; - await controller.getList(queryInfo, {}); + await controller.getList(queryInfo); expect(surveyHistoryService.getHistoryList).toHaveBeenCalledWith({ surveyId: queryInfo.surveyId, diff --git a/server/src/modules/survey/__test/surveyHistory.service.spec.ts b/server/src/modules/survey/__test/surveyHistory.service.spec.ts index f226cfc0..3ab1d2f5 100644 --- a/server/src/modules/survey/__test/surveyHistory.service.spec.ts +++ b/server/src/modules/survey/__test/surveyHistory.service.spec.ts @@ -42,7 +42,7 @@ describe('SurveyHistoryService', () => { msgContent: undefined, }, baseConf: { - begTime: '', + beginTime: '', endTime: '', answerBegTime: '', answerEndTime: '', @@ -78,7 +78,12 @@ describe('SurveyHistoryService', () => { .spyOn(repository, 'save') .mockResolvedValueOnce({} as SurveyHistory); - await service.addHistory({ surveyId, schema, type, user }); + await service.addHistory({ + surveyId, + schema, + type, + user, + }); expect(spyCreate).toHaveBeenCalledWith({ pageId: surveyId, diff --git a/server/src/modules/survey/__test/surveyMeta.controller.spec.ts b/server/src/modules/survey/__test/surveyMeta.controller.spec.ts index 936728e1..4d567850 100644 --- a/server/src/modules/survey/__test/surveyMeta.controller.spec.ts +++ b/server/src/modules/survey/__test/surveyMeta.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SurveyMetaController } from '../controllers/surveyMeta.controller'; import { SurveyMetaService } from '../services/surveyMeta.service'; -import { LoggerProvider } from 'src/logger/logger.provider'; +import { Logger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { CollaboratorService } from '../services/collaborator.service'; @@ -28,7 +28,12 @@ describe('SurveyMetaController', () => { .mockResolvedValue({ count: 0, data: [] }), }, }, - LoggerProvider, + { + provide: Logger, + useValue: { + error() {}, + }, + }, { provide: CollaboratorService, useValue: { @@ -119,6 +124,7 @@ describe('SurveyMetaController', () => { subStatus: { date: date, }, + surveyType: 'normal', }, ], }); @@ -145,10 +151,12 @@ describe('SurveyMetaController', () => { /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/, ), }), + surveyType: 'normal', }), ]), }, }); + expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({ pageNum: queryInfo.curPage, pageSize: queryInfo.pageSize, @@ -199,4 +207,24 @@ describe('SurveyMetaController', () => { workspaceId: undefined, }); }); + + it('should handle Joi validation in getList', async () => { + const invalidQueryInfo: any = { + curPage: 'invalid', + pageSize: 10, + }; + const req = { + user: { + username: 'test-user', + _id: new ObjectId(), + }, + }; + + try { + await controller.getList(invalidQueryInfo, req); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect(error.code).toBe(EXCEPTION_CODE.PARAMETER_ERROR); + } + }); }); diff --git a/server/src/modules/survey/__test/surveyMeta.service.spec.ts b/server/src/modules/survey/__test/surveyMeta.service.spec.ts index 26d3a579..3e15ebd3 100644 --- a/server/src/modules/survey/__test/surveyMeta.service.spec.ts +++ b/server/src/modules/survey/__test/surveyMeta.service.spec.ts @@ -3,7 +3,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service'; import { MongoRepository } from 'typeorm'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums'; import { getRepositoryToken } from '@nestjs/typeorm'; import { HttpException } from 'src/exceptions/httpException'; @@ -13,7 +13,7 @@ import { ObjectId } from 'mongodb'; describe('SurveyMetaService', () => { let service: SurveyMetaService; let surveyRepository: MongoRepository; - let pluginManager: XiaojuSurveyPluginManager; + let pluginManager: PluginManager; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -37,9 +37,7 @@ describe('SurveyMetaService', () => { surveyRepository = module.get>( getRepositoryToken(SurveyMeta), ); - pluginManager = module.get( - XiaojuSurveyPluginManager, - ); + pluginManager = module.get(PluginManager); pluginManager.registerPlugin(new SurveyUtilPlugin()); }); @@ -109,9 +107,9 @@ describe('SurveyMetaService', () => { const result = await service.editSurveyMeta(survey); - expect(survey.subStatus.status).toEqual(RECORD_SUB_STATUS.EDITING); + expect(survey.curStatus.status).toEqual(RECORD_STATUS.EDITING); expect(survey.statusList.length).toBe(1); - expect(survey.statusList[0].status).toEqual(RECORD_SUB_STATUS.EDITING); + expect(survey.statusList[0].status).toEqual(RECORD_STATUS.EDITING); expect(surveyRepository.save).toHaveBeenCalledWith(survey); expect(result).toEqual(survey); }); @@ -136,9 +134,9 @@ describe('SurveyMetaService', () => { // 验证结果 expect(result).toBe(survey); - expect(survey.subStatus.status).toBe(RECORD_SUB_STATUS.REMOVED); + expect(survey.subStatus.status).toBe(RECORD_STATUS.REMOVED); expect(survey.statusList.length).toBe(1); - expect(survey.statusList[0].status).toBe(RECORD_SUB_STATUS.REMOVED); + expect(survey.statusList[0].status).toBe(RECORD_STATUS.REMOVED); expect(surveyRepository.save).toHaveBeenCalledTimes(1); expect(surveyRepository.save).toHaveBeenCalledWith(survey); }); @@ -146,8 +144,8 @@ describe('SurveyMetaService', () => { it('should throw exception when survey is already removed', async () => { // 准备假的SurveyMeta对象,其状态已设置为REMOVED const survey = new SurveyMeta(); - survey.subStatus = { - status: RECORD_SUB_STATUS.REMOVED, + survey.curStatus = { + status: RECORD_STATUS.REMOVED, date: Date.now(), }; diff --git a/server/src/modules/survey/controllers/collaborator.controller.ts b/server/src/modules/survey/controllers/collaborator.controller.ts index 08c97068..be6b5a6e 100644 --- a/server/src/modules/survey/controllers/collaborator.controller.ts +++ b/server/src/modules/survey/controllers/collaborator.controller.ts @@ -69,7 +69,7 @@ export class CollaboratorController { ) { const { error, value } = CreateCollaboratorDto.validate(reqBody); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException( '系统错误,请联系管理员', EXCEPTION_CODE.PARAMETER_ERROR, @@ -124,7 +124,7 @@ export class CollaboratorController { ) { const { error, value } = BatchSaveCollaboratorDto.validate(reqBody); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException( '系统错误,请联系管理员', EXCEPTION_CODE.PARAMETER_ERROR, @@ -184,11 +184,15 @@ export class CollaboratorController { neIdList: collaboratorIdList, userIdList: newCollaboratorUserIdList, }); - this.logger.info('batchDelete:' + JSON.stringify(delRes), { req }); + this.logger.info('batchDelete:' + JSON.stringify(delRes)); + const username = req.user.username; + const userId = req.user._id.toString(); if (Array.isArray(newCollaborator) && newCollaborator.length > 0) { const insertRes = await this.collaboratorService.batchCreate({ surveyId: value.surveyId, collaboratorList: newCollaborator, + creator: username, + creatorId: userId, }); this.logger.info(`${JSON.stringify(insertRes)}`); } @@ -198,6 +202,8 @@ export class CollaboratorController { this.collaboratorService.updateById({ collaboratorId: item._id, permissions: item.permissions, + operator: username, + operatorId: userId, }), ), ); @@ -208,7 +214,7 @@ export class CollaboratorController { const delRes = await this.collaboratorService.batchDeleteBySurveyId( value.surveyId, ); - this.logger.info(JSON.stringify(delRes), { req }); + this.logger.info(JSON.stringify(delRes)); } return { @@ -225,11 +231,10 @@ export class CollaboratorController { ]) async getSurveyCollaboratorList( @Query() query: GetSurveyCollaboratorListDto, - @Request() req, ) { const { error, value } = GetSurveyCollaboratorListDto.validate(query); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -263,17 +268,14 @@ export class CollaboratorController { @SetMetadata('surveyPermission', [ SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, ]) - async changeUserPermission( - @Body() reqBody: ChangeUserPermissionDto, - @Request() req, - ) { + async changeUserPermission(@Body() reqBody: ChangeUserPermissionDto) { const { error, value } = Joi.object({ surveyId: Joi.string(), userId: Joi.string(), permissions: Joi.array().items(Joi.string().required()), }).validate(reqBody); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -292,13 +294,13 @@ export class CollaboratorController { @SetMetadata('surveyPermission', [ SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, ]) - async deleteCollaborator(@Query() query, @Request() req) { + async deleteCollaborator(@Query() query) { const { error, value } = Joi.object({ surveyId: Joi.string(), userId: Joi.string(), }).validate(query); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -319,7 +321,7 @@ export class CollaboratorController { const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); if (!surveyMeta) { - this.logger.error(`问卷不存在: ${surveyId}`, { req }); + this.logger.error(`问卷不存在: ${surveyId}`); throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND); } diff --git a/server/src/modules/survey/controllers/dataStatistic.controller.ts b/server/src/modules/survey/controllers/dataStatistic.controller.ts index 499744b4..185e6354 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -5,7 +5,6 @@ import { HttpCode, UseGuards, SetMetadata, - Request, } from '@nestjs/common'; import * as Joi from 'joi'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; @@ -14,7 +13,7 @@ import { DataStatisticService } from '../services/dataStatistic.service'; import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; import { Authentication } from 'src/guards/authentication.guard'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; import { SurveyGuard } from 'src/guards/survey.guard'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { Logger } from 'src/logger'; @@ -31,7 +30,7 @@ export class DataStatisticController { constructor( private readonly responseSchemaService: ResponseSchemaService, private readonly dataStatisticService: DataStatisticService, - private readonly pluginManager: XiaojuSurveyPluginManager, + private readonly pluginManager: PluginManager, private readonly logger: Logger, ) {} @@ -44,19 +43,18 @@ export class DataStatisticController { async data( @Query() queryInfo, - @Request() req, ) { const { value, error } = await Joi.object({ surveyId: Joi.string().required(), - isDesensitive: Joi.boolean().default(true), // 默认true就是需要脱敏 + isMasked: Joi.boolean().default(true), // 默认true就是需要脱敏 page: Joi.number().default(1), pageSize: Joi.number().default(10), }).validate(queryInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } - const { surveyId, isDesensitive, page, pageSize } = value; + const { surveyId, isMasked, page, pageSize } = value; const responseSchema = await this.responseSchemaService.getResponseSchemaByPageId(surveyId); const { total, listHead, listBody } = @@ -67,10 +65,10 @@ export class DataStatisticController { pageSize, }); - if (isDesensitive) { + if (isMasked) { // 脱敏 listBody.forEach((item) => { - this.pluginManager.triggerHook('desensitiveData', item); + this.pluginManager.triggerHook('maskData', item); }); } diff --git a/server/src/modules/survey/controllers/downloadTask.controller.ts b/server/src/modules/survey/controllers/downloadTask.controller.ts new file mode 100644 index 00000000..4a83baab --- /dev/null +++ b/server/src/modules/survey/controllers/downloadTask.controller.ts @@ -0,0 +1,189 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + SetMetadata, + Request, + Post, + Body, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; + +import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { DownloadTaskService } from '../services/downloadTask.service'; +import { + GetDownloadTaskDto, + CreateDownloadDto, + GetDownloadTaskListDto, + DeleteDownloadTaskDto, +} from '../dto/downloadTask.dto'; +import moment from 'moment'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; + +@ApiTags('downloadTask') +@ApiBearerAuth() +@Controller('/api/downloadTask') +export class DownloadTaskController { + constructor( + private readonly responseSchemaService: ResponseSchemaService, + private readonly downloadTaskService: DownloadTaskService, + private readonly logger: Logger, + ) {} + + @Post('/createTask') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE]) + @UseGuards(Authentication) + async createTask( + @Body() + reqBody: CreateDownloadDto, + @Request() req, + ) { + const { value, error } = CreateDownloadDto.validate(reqBody); + if (error) { + this.logger.error(error.message); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { surveyId, isMasked } = value; + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const id = await this.downloadTaskService.createDownloadTask({ + surveyId, + responseSchema, + creatorId: req.user._id.toString(), + creator: req.user.username, + params: { isMasked }, + }); + this.downloadTaskService.processDownloadTask({ taskId: id }); + return { + code: 200, + data: { taskId: id }, + }; + } + + @Get('/getDownloadTaskList') + @HttpCode(200) + @UseGuards(Authentication) + async downloadList( + @Query() + queryInfo: GetDownloadTaskListDto, + @Request() req, + ) { + const { value, error } = GetDownloadTaskListDto.validate(queryInfo); + if (error) { + this.logger.error(error.message); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { pageIndex, pageSize } = value; + const { total, list } = await this.downloadTaskService.getDownloadTaskList({ + creatorId: req.user._id.toString(), + pageIndex, + pageSize, + }); + return { + code: 200, + data: { + total: total, + list: list.map((data) => { + const item: Record = {}; + item.taskId = data._id.toString(); + item.status = data.status; + item.filename = data.filename; + item.url = data.url; + const fmt = 'YYYY-MM-DD HH:mm:ss'; + const units = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + let unitIndex = 0; + let size = Number(data.fileSize); + if (isNaN(size)) { + item.fileSize = data.fileSize; + } else { + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + item.fileSize = `${size.toFixed()} ${units[unitIndex]}`; + } + item.createdAt = moment(data.createdAt).format(fmt); + return item; + }), + }, + }; + } + + @Get('/getDownloadTask') + @HttpCode(200) + @UseGuards(Authentication) + async getDownloadTask(@Query() query: GetDownloadTaskDto, @Request() req) { + const { value, error } = GetDownloadTaskDto.validate(query); + if (error) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const taskInfo = await this.downloadTaskService.getDownloadTaskById({ + taskId: value.taskId, + }); + + if (!taskInfo) { + throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR); + } + + if (taskInfo.creatorId !== req.user._id.toString()) { + throw new NoPermissionException('没有权限'); + } + const res: Record = { + ...taskInfo, + }; + res.taskId = taskInfo._id.toString(); + delete res._id; + + return { + code: 200, + data: res, + }; + } + + @Post('/deleteDownloadTask') + @HttpCode(200) + @UseGuards(Authentication) + async deleteFileByName(@Body() body: DeleteDownloadTaskDto, @Request() req) { + const { value, error } = DeleteDownloadTaskDto.validate(body); + if (error) { + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const { taskId } = value; + + const taskInfo = await this.downloadTaskService.getDownloadTaskById({ + taskId, + }); + + if (!taskInfo) { + throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR); + } + + if (taskInfo.creatorId !== req.user._id.toString()) { + throw new NoPermissionException('没有权限'); + } + + const delRes = await this.downloadTaskService.deleteDownloadTask({ + taskId, + operator: req.user.username, + operatorId: req.user._id.toString(), + }); + + return { + code: 200, + data: delRes.modifiedCount === 1, + }; + } +} diff --git a/server/src/modules/survey/controllers/session.controller.ts b/server/src/modules/survey/controllers/session.controller.ts new file mode 100644 index 00000000..93f01528 --- /dev/null +++ b/server/src/modules/survey/controllers/session.controller.ts @@ -0,0 +1,90 @@ +import { + Controller, + Post, + Body, + HttpCode, + UseGuards, + SetMetadata, + Request, +} from '@nestjs/common'; +import * as Joi from 'joi'; +import { ApiTags } from '@nestjs/swagger'; + +import { SessionService } from '../services/session.service'; + +import { Authentication } from 'src/guards/authentication.guard'; +import { SurveyGuard } from 'src/guards/survey.guard'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { Logger } from 'src/logger'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { SessionGuard } from 'src/guards/session.guard'; + +@ApiTags('survey') +@Controller('/api/session') +export class SessionController { + constructor( + private readonly sessionService: SessionService, + private readonly logger: Logger, + ) {} + + @Post('/create') + @HttpCode(200) + @UseGuards(SurveyGuard) + @SetMetadata('surveyId', 'body.surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) + async create( + @Body() + reqBody: { + surveyId: string; + }, + @Request() + req, + ) { + const { value, error } = Joi.object({ + surveyId: Joi.string().required(), + }).validate(reqBody); + + if (error) { + this.logger.error(error.message); + throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); + } + + const surveyId = value.surveyId; + const session = await this.sessionService.create({ + surveyId, + userId: req.user._id.toString(), + }); + + return { + code: 200, + data: { + sessionId: session._id.toString(), + }, + }; + } + + @Post('/seize') + @HttpCode(200) + @UseGuards(SessionGuard, SurveyGuard) + @SetMetadata('sessionId', 'body.sessionId') + @SetMetadata('surveyId', 'surveyId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) + async seize( + @Request() + req, + ) { + const sessionInfo = req.sessionInfo; + + await this.sessionService.updateSessionToEditing({ + sessionId: sessionInfo._id.toString(), + surveyId: sessionInfo.surveyId, + }); + + return { + code: 200, + }; + } +} diff --git a/server/src/modules/survey/controllers/survey.controller.ts b/server/src/modules/survey/controllers/survey.controller.ts index 69fd4aba..98d32d89 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -31,7 +31,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { WorkspaceGuard } from 'src/guards/workspace.guard'; import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; -import { MemberType, WhitelistType } from 'src/interfaces/survey'; +import { SessionService } from '../services/session.service'; +import { UserService } from 'src/modules/auth/services/user.service'; @ApiTags('survey') @Controller('/api/survey') @@ -43,6 +44,8 @@ export class SurveyController { private readonly contentSecurityService: ContentSecurityService, private readonly surveyHistoryService: SurveyHistoryService, private readonly logger: Logger, + private readonly sessionService: SessionService, + private readonly userService: UserService, ) {} @Get('/getBannerData') @@ -71,9 +74,7 @@ export class SurveyController { ) { const { error, value } = CreateSurveyDto.validate(reqBody); if (error) { - this.logger.error(`createSurvey_parameter error: ${error.message}`, { - req, - }); + this.logger.error(`createSurvey_parameter error: ${error.message}`); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -129,13 +130,41 @@ export class SurveyController { const { value, error } = Joi.object({ surveyId: Joi.string().required(), configData: Joi.any().required(), + sessionId: Joi.string().required(), }).validate(surveyInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } - const username = req.user.username; + const sessionId = value.sessionId; const surveyId = value.surveyId; + const latestEditingOne = await this.sessionService.findLatestEditingOne({ + surveyId, + }); + + if (latestEditingOne && latestEditingOne._id.toString() !== sessionId) { + const curSession = await this.sessionService.findOne(sessionId); + if (curSession.createdAt <= latestEditingOne.updatedAt) { + // 在当前用户打开之后,被其他页面保存过了 + const isSameOperator = + latestEditingOne.userId === req.user._id.toString(); + let preOperator; + if (!isSameOperator) { + preOperator = await this.userService.getUserById( + latestEditingOne.userId, + ); + } + return { + code: EXCEPTION_CODE.SURVEY_SAVE_CONFLICT, + errmsg: isSameOperator + ? '当前问卷已在其它页面开启编辑,刷新以获取最新内容' + : `当前问卷已由 ${preOperator.username} 编辑,刷新以获取最新内容`, + }; + } + } + await this.sessionService.updateSessionToEditing({ sessionId, surveyId }); + + const username = req.user.username; const configData = value.configData; await this.surveyConfService.saveSurveyConf({ @@ -165,10 +194,18 @@ export class SurveyController { async deleteSurvey(@Request() req) { const surveyMeta = req.surveyMeta; - await this.surveyMetaService.deleteSurveyMeta(surveyMeta); - await this.responseSchemaService.deleteResponseSchema({ - surveyPath: surveyMeta.surveyPath, + const delMetaRes = await this.surveyMetaService.deleteSurveyMeta({ + surveyId: surveyMeta._id.toString(), + operator: req.user.username, + operatorId: req.user._id.toString(), }); + const delResponseRes = + await this.responseSchemaService.deleteResponseSchema({ + surveyPath: surveyMeta.surveyPath, + }); + + this.logger.info(JSON.stringify(delMetaRes)); + this.logger.info(JSON.stringify(delResponseRes)); return { code: 200, @@ -217,7 +254,7 @@ export class SurveyController { }).validate(queryInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -234,16 +271,6 @@ export class SurveyController { surveyMeta.isCollaborated = false; } - // 白名单相关字段的默认值 - const baseConf = surveyConf.code?.baseConf; - if (baseConf) { - baseConf.passwordSwitch = baseConf.passwordSwitch ?? false; - baseConf.password = baseConf.password ?? ''; - baseConf.whitelistType = baseConf.whitelistType ?? WhitelistType.ALL; - baseConf.whitelist = baseConf.whitelist ?? []; - baseConf.memberType = baseConf.memberType ?? MemberType.MOBILE; - } - return { code: 200, data: { @@ -260,15 +287,13 @@ export class SurveyController { queryInfo: { surveyPath: string; }, - @Request() - req, ) { const { value, error } = Joi.object({ surveyId: Joi.string().required(), }).validate({ surveyId: queryInfo.surveyPath }); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } const surveyId = value.surveyId; @@ -301,12 +326,18 @@ export class SurveyController { surveyId: Joi.string().required(), }).validate(surveyInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } const username = req.user.username; const surveyId = value.surveyId; const surveyMeta = req.surveyMeta; + if (surveyMeta.isDeleted) { + throw new HttpException( + '问卷已删除,无法发布', + EXCEPTION_CODE.SURVEY_NOT_FOUND, + ); + } const surveyConf = await this.surveyConfService.getSurveyConfBySurveyId(surveyId); @@ -332,7 +363,8 @@ export class SurveyController { pageId: surveyId, }); - await this.surveyHistoryService.addHistory({ + // 添加发布历史可以异步添加 + this.surveyHistoryService.addHistory({ surveyId, schema: surveyConf.code, type: HISTORY_TYPE.PUBLISH_HIS, diff --git a/server/src/modules/survey/controllers/surveyHistory.controller.ts b/server/src/modules/survey/controllers/surveyHistory.controller.ts index 6144fa3d..220cae4b 100644 --- a/server/src/modules/survey/controllers/surveyHistory.controller.ts +++ b/server/src/modules/survey/controllers/surveyHistory.controller.ts @@ -5,7 +5,6 @@ import { HttpCode, UseGuards, SetMetadata, - Request, } from '@nestjs/common'; import * as Joi from 'joi'; import { ApiTags } from '@nestjs/swagger'; @@ -18,9 +17,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { Logger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; - @ApiTags('survey') -@Controller('/api/surveyHisotry') +@Controller('/api/surveyHistory') export class SurveyHistoryController { constructor( private readonly surveyHistoryService: SurveyHistoryService, @@ -43,7 +41,6 @@ export class SurveyHistoryController { surveyId: string; historyType: string; }, - @Request() req, ) { const { value, error } = Joi.object({ surveyId: Joi.string().required(), @@ -51,7 +48,7 @@ export class SurveyHistoryController { }).validate(queryInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } diff --git a/server/src/modules/survey/controllers/surveyMeta.controller.ts b/server/src/modules/survey/controllers/surveyMeta.controller.ts index 048fd98a..5acf41d5 100644 --- a/server/src/modules/survey/controllers/surveyMeta.controller.ts +++ b/server/src/modules/survey/controllers/surveyMeta.controller.ts @@ -51,16 +51,18 @@ export class SurveyMetaController { }).validate(reqBody, { allowUnknown: true }); if (error) { - this.logger.error(`updateMeta_parameter error: ${error.message}`, { - req, - }); + this.logger.error(`updateMeta_parameter error: ${error.message}`); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } const survey = req.surveyMeta; survey.title = value.title; survey.remark = value.remark; - await this.surveyMetaService.editSurveyMeta(survey); + await this.surveyMetaService.editSurveyMeta({ + survey, + operator: req.user.username, + operatorId: req.user._id.toString(), + }); return { code: 200, @@ -81,7 +83,7 @@ export class SurveyMetaController { ) { const { value, error } = GetSurveyListDto.validate(queryInfo); if (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR); } const { curPage, pageSize, workspaceId } = value; @@ -91,14 +93,14 @@ export class SurveyMetaController { try { filter = getFilter(JSON.parse(decodeURIComponent(value.filter))); } catch (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); } } if (value.order) { try { order = order = getOrder(JSON.parse(decodeURIComponent(value.order))); } catch (error) { - this.logger.error(error.message, { req }); + this.logger.error(error.message); } } const userId = req.user._id.toString(); @@ -129,9 +131,10 @@ export class SurveyMetaController { if (!item.surveyType) { item.surveyType = item.questionType || 'normal'; } - item.createDate = moment(item.createDate).format(fmt); + item.createdAt = moment(item.createdAt).format(fmt); item.curStatus.date = moment(item.curStatus.date).format(fmt); item.subStatus.date = moment(item.subStatus.date).format(fmt); + item.updatedAt = moment(item.updatedAt).format(fmt); const surveyId = item._id.toString(); if (cooperSurveyIdMap[surveyId]) { item.isCollaborated = true; diff --git a/server/src/modules/survey/dto/createSurvey.dto.ts b/server/src/modules/survey/dto/createSurvey.dto.ts index 537996e6..f34ea9fb 100644 --- a/server/src/modules/survey/dto/createSurvey.dto.ts +++ b/server/src/modules/survey/dto/createSurvey.dto.ts @@ -12,10 +12,10 @@ export class CreateSurveyDto { surveyType: string; @ApiProperty({ description: '创建方法', required: false }) - createMethod: string; + createMethod?: string; @ApiProperty({ description: '创建来源', required: false }) - createFrom: string; + createFrom?: string; @ApiProperty({ description: '问卷创建在哪个空间下', required: false }) workspaceId?: string; diff --git a/server/src/modules/survey/dto/downloadTask.dto.ts b/server/src/modules/survey/dto/downloadTask.dto.ts new file mode 100644 index 00000000..6a950eb1 --- /dev/null +++ b/server/src/modules/survey/dto/downloadTask.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class CreateDownloadDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + @ApiProperty({ description: '是否脱敏', required: false }) + isMasked: boolean; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + isMasked: Joi.boolean().allow(null).default(false), + }).validate(data); + } +} +export class GetDownloadTaskListDto { + @ApiProperty({ description: '当前页', required: false }) + pageIndex: number; + @ApiProperty({ description: '一页大小', required: false }) + pageSize: number; + + static validate(data) { + return Joi.object({ + pageIndex: Joi.number().default(1), + pageSize: Joi.number().default(20), + }).validate(data); + } +} + +export class GetDownloadTaskDto { + @ApiProperty({ description: '任务id', required: true }) + taskId: string; + + static validate(data) { + return Joi.object({ + taskId: Joi.string().required(), + }).validate(data); + } +} + +export class DeleteDownloadTaskDto { + @ApiProperty({ description: '任务id', required: true }) + taskId: string; + + static validate(data) { + return Joi.object({ + taskId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/survey/services/collaborator.service.ts b/server/src/modules/survey/services/collaborator.service.ts index cfd6c628..169c24f8 100644 --- a/server/src/modules/survey/services/collaborator.service.ts +++ b/server/src/modules/survey/services/collaborator.service.ts @@ -22,12 +22,17 @@ export class CollaboratorService { return this.collaboratorRepository.save(collaborator); } - async batchCreate({ surveyId, collaboratorList }) { + async batchCreate({ surveyId, collaboratorList, creator, creatorId }) { + const now = new Date(); const res = await this.collaboratorRepository.insertMany( collaboratorList.map((item) => { return { ...item, surveyId, + createdAt: now, + updatedAt: now, + creator, + creatorId, }; }), ); @@ -60,7 +65,13 @@ export class CollaboratorService { return info; } - async changeUserPermission({ userId, surveyId, permission }) { + async changeUserPermission({ + userId, + surveyId, + permission, + operator, + operatorId, + }) { const updateRes = await this.collaboratorRepository.updateOne( { surveyId, @@ -69,6 +80,9 @@ export class CollaboratorService { { $set: { permission, + operator, + operatorId, + updatedAt: new Date(), }, }, ); @@ -134,7 +148,7 @@ export class CollaboratorService { return delRes; } - updateById({ collaboratorId, permissions }) { + updateById({ collaboratorId, permissions, operator, operatorId }) { return this.collaboratorRepository.updateOne( { _id: new ObjectId(collaboratorId), @@ -142,6 +156,9 @@ export class CollaboratorService { { $set: { permissions, + operator, + operatorId, + updatedAt: new Date(), }, }, ); diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index 022f7cab..fbb29ec5 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MongoRepository } from 'typeorm'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; -import { RECORD_SUB_STATUS } from 'src/enums'; import moment from 'moment'; import { keyBy } from 'lodash'; @@ -35,8 +34,8 @@ export class DataStatisticService { const dataListMap = keyBy(dataList, 'field'); const where = { pageId: surveyId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }; const [surveyResponseList, total] = @@ -45,7 +44,7 @@ export class DataStatisticService { take: pageSize, skip: (pageNum - 1) * pageSize, order: { - createDate: -1, + createdAt: -1, }, }); @@ -91,10 +90,10 @@ export class DataStatisticService { } return { ...data, - diffTime: (submitedData.diffTime / 1000).toFixed(2), - createDate: moment(submitedData.createDate).format( - 'YYYY-MM-DD HH:mm:ss', - ), + diffTime: submitedData.diffTime + ? (submitedData.diffTime / 1000).toFixed(2) + : '0', + createdAt: moment(submitedData.createdAt).format('YYYY-MM-DD HH:mm:ss'), }; }); return { @@ -125,8 +124,8 @@ export class DataStatisticService { { $match: { pageId: surveyId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }, }, diff --git a/server/src/modules/survey/services/downloadTask.service.ts b/server/src/modules/survey/services/downloadTask.service.ts new file mode 100644 index 00000000..3808730d --- /dev/null +++ b/server/src/modules/survey/services/downloadTask.service.ts @@ -0,0 +1,273 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { DownloadTask } from 'src/models/downloadTask.entity'; +import { ObjectId } from 'mongodb'; +import { ResponseSchemaService } from 'src/modules/surveyResponse/services/responseScheme.service'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { DataStatisticService } from './dataStatistic.service'; +import xlsx from 'node-xlsx'; +import { load } from 'cheerio'; +import { get } from 'lodash'; +import { FileService } from 'src/modules/file/services/file.service'; +import { Logger } from 'src/logger'; +import moment from 'moment'; +import { DOWNLOAD_TASK_STATUS } from 'src/enums/downloadTaskStatus'; + +@Injectable() +export class DownloadTaskService { + private static taskList: Array = []; + private static isExecuting: boolean = false; + + constructor( + @InjectRepository(DownloadTask) + private readonly downloadTaskRepository: MongoRepository, + private readonly responseSchemaService: ResponseSchemaService, + @InjectRepository(SurveyResponse) + private readonly surveyResponseRepository: MongoRepository, + private readonly dataStatisticService: DataStatisticService, + private readonly fileService: FileService, + private readonly logger: Logger, + ) {} + + async createDownloadTask({ + surveyId, + responseSchema, + creatorId, + creator, + params, + }: { + surveyId: string; + responseSchema: ResponseSchema; + creatorId: string; + creator: string; + params: any; + }) { + const filename = `${responseSchema.title}-${params.isMasked ? '脱敏' : '原'}回收数据-${moment().format('YYYYMMDDHHmmss')}.xlsx`; + const downloadTask = this.downloadTaskRepository.create({ + surveyId, + surveyPath: responseSchema.surveyPath, + fileSize: '计算中', + creatorId, + creator, + params: { + ...params, + title: responseSchema.title, + }, + filename, + status: DOWNLOAD_TASK_STATUS.WAITING, + }); + await this.downloadTaskRepository.save(downloadTask); + return downloadTask._id.toString(); + } + + async getDownloadTaskList({ + creatorId, + pageIndex, + pageSize, + }: { + creatorId: string; + pageIndex: number; + pageSize: number; + }) { + const where = { + creatorId, + isDeleted: { + $ne: true, + }, + }; + const [surveyDownloadList, total] = + await this.downloadTaskRepository.findAndCount({ + where, + take: pageSize, + skip: (pageIndex - 1) * pageSize, + order: { + createdAt: -1, + }, + }); + return { + total, + list: surveyDownloadList, + }; + } + + async getDownloadTaskById({ taskId }) { + const res = await this.downloadTaskRepository.find({ + where: { + _id: new ObjectId(taskId), + }, + }); + if (Array.isArray(res) && res.length > 0) { + return res[0]; + } + return null; + } + + async deleteDownloadTask({ + taskId, + operator, + operatorId, + }: { + taskId: string; + operator: string; + operatorId: string; + }) { + return this.downloadTaskRepository.updateOne( + { + _id: new ObjectId(taskId), + }, + { + $set: { + isDeleted: true, + operator, + operatorId, + deletedAt: new Date(), + }, + }, + ); + } + + processDownloadTask({ taskId }) { + DownloadTaskService.taskList.push(taskId); + if (!DownloadTaskService.isExecuting) { + this.executeTask(); + DownloadTaskService.isExecuting = true; + } + } + + async executeTask() { + try { + while (DownloadTaskService.taskList.length > 0) { + const taskId = DownloadTaskService.taskList.shift(); + this.logger.info(`handle taskId: ${taskId}`); + const taskInfo = await this.getDownloadTaskById({ taskId }); + if (!taskInfo || taskInfo.isDeleted) { + // 不存在或者已删除的,不处理 + continue; + } + await this.handleDownloadTask({ taskInfo }); + } + } finally { + DownloadTaskService.isExecuting = false; + } + } + + private async handleDownloadTask({ taskInfo }) { + try { + // 更新任务状态为计算中 + const updateRes = await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + status: DOWNLOAD_TASK_STATUS.COMPUTING, + updatedAt: new Date(), + }, + }, + ); + + this.logger.info(JSON.stringify(updateRes)); + + // 开始计算任务 + const surveyId = taskInfo.surveyId; + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const where = { + pageId: surveyId, + }; + const total = await this.surveyResponseRepository.count(where); + const pageSize = 200; + const pageTotal = Math.ceil(total / pageSize); + const xlsxHead = []; + const xlsxBody = []; + for (let pageIndex = 1; pageIndex <= pageTotal; pageIndex++) { + const { listHead, listBody } = + await this.dataStatisticService.getDataTable({ + surveyId, + pageNum: pageIndex, + pageSize, + responseSchema, + }); + if (xlsxHead.length === 0) { + for (const item of listHead) { + const $ = load(item.title); + const text = $.text(); + xlsxHead.push(text); + } + } + for (const bodyItem of listBody) { + const bodyData = []; + for (const headItem of listHead) { + const field = headItem.field; + const val = get(bodyItem, field, ''); + if (typeof val === 'string') { + const $ = load(val); + const text = $.text(); + bodyData.push(text); + } else { + bodyData.push(val); + } + } + xlsxBody.push(bodyData); + } + } + const xlsxData = [xlsxHead, ...xlsxBody]; + const buffer = await xlsx.build([ + { name: 'sheet1', data: xlsxData, options: {} }, + ]); + + const file: Express.Multer.File = { + fieldname: 'file', + originalname: taskInfo.filename, + encoding: '7bit', + mimetype: 'application/octet-stream', + filename: taskInfo.filename, + size: buffer.length, + buffer: buffer, + stream: null, + destination: null, + path: '', + }; + const { url, key } = await this.fileService.upload({ + configKey: 'SERVER_LOCAL_CONFIG', + file, + pathPrefix: 'exportfile', + filename: taskInfo.filename, + }); + + // 更新计算结果 + const updateFinishRes = await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + status: DOWNLOAD_TASK_STATUS.SUCCEED, + url, + fileKey: key, + fileSize: buffer.length, + updatedAt: new Date(), + }, + }, + ); + this.logger.info(JSON.stringify(updateFinishRes)); + } catch (error) { + await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + status: DOWNLOAD_TASK_STATUS.FAILED, + updatedAt: new Date(), + }, + }, + ); + this.logger.error( + `导出文件失败 taskId: ${taskInfo._id.toString()}, surveyId: ${taskInfo.surveyId}, message: ${error.message}`, + ); + } + } +} diff --git a/server/src/modules/survey/services/session.service.ts b/server/src/modules/survey/services/session.service.ts new file mode 100644 index 00000000..a0060d8e --- /dev/null +++ b/server/src/modules/survey/services/session.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MongoRepository } from 'typeorm'; +import { Session } from 'src/models/session.entity'; +import { ObjectId } from 'mongodb'; +import { SESSION_STATUS } from 'src/enums/surveySessionStatus'; + +@Injectable() +export class SessionService { + constructor( + @InjectRepository(Session) + private readonly sessionRepository: MongoRepository, + ) {} + + create({ surveyId, userId }) { + const session = this.sessionRepository.create({ + surveyId, + userId, + status: SESSION_STATUS.DEACTIVATED, + }); + return this.sessionRepository.save(session); + } + + findOne(sessionId) { + return this.sessionRepository.findOne({ + where: { + _id: new ObjectId(sessionId), + }, + }); + } + + findLatestEditingOne({ surveyId }) { + return this.sessionRepository.findOne({ + where: { + surveyId, + status: SESSION_STATUS.ACTIVATED, + }, + }); + } + + updateSessionToEditing({ sessionId, surveyId }) { + return Promise.all([ + this.sessionRepository.update( + { + _id: new ObjectId(sessionId), + }, + { + status: SESSION_STATUS.ACTIVATED, + updatedAt: new Date(), + }, + ), + this.sessionRepository.updateMany( + { + surveyId, + _id: { + $ne: new ObjectId(sessionId), + }, + }, + { + $set: { + status: SESSION_STATUS.DEACTIVATED, + updatedAt: new Date(), + }, + }, + ), + ]); + } +} diff --git a/server/src/modules/survey/services/surveyHistory.service.ts b/server/src/modules/survey/services/surveyHistory.service.ts index 0b512faf..0eda2c8f 100644 --- a/server/src/modules/survey/services/surveyHistory.service.ts +++ b/server/src/modules/survey/services/surveyHistory.service.ts @@ -45,9 +45,9 @@ export class SurveyHistoryService { }, take: 100, order: { - createDate: -1, + createdAt: -1, }, - select: ['createDate', 'operator', 'type', '_id'], + select: ['createdAt', 'operator', 'type', '_id'], }); } } diff --git a/server/src/modules/survey/services/surveyMeta.service.ts b/server/src/modules/survey/services/surveyMeta.service.ts index 69cf8ec8..71a160a8 100644 --- a/server/src/modules/survey/services/surveyMeta.service.ts +++ b/server/src/modules/survey/services/surveyMeta.service.ts @@ -6,14 +6,14 @@ import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums'; import { ObjectId } from 'mongodb'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; @Injectable() export class SurveyMetaService { constructor( @InjectRepository(SurveyMeta) private readonly surveyRepository: MongoRepository, - private readonly pluginManager: XiaojuSurveyPluginManager, + private readonly pluginManager: PluginManager, ) {} async getNewSurveyPath(): Promise { @@ -65,6 +65,7 @@ export class SurveyMetaService { surveyType: surveyType, surveyPath, creator: username, + creatorId: userId, owner: username, ownerId: userId, createMethod, @@ -76,11 +77,7 @@ export class SurveyMetaService { } async pausingSurveyMeta(survey: SurveyMeta) { - if ( - survey.curStatus.status !== RECORD_STATUS.PUBLISHED || - (survey?.subStatus?.status && - survey?.subStatus?.status != RECORD_SUB_STATUS.EDITING) - ) { + if (survey?.curStatus?.status === RECORD_STATUS.NEW) { throw new HttpException( '问卷不能暂停', EXCEPTION_CODE.SURVEY_STATUS_TRANSFORM_ERROR, @@ -91,7 +88,6 @@ export class SurveyMetaService { date: Date.now(), }; survey.subStatus = subCurStatus; - survey.curStatus.status = RECORD_STATUS.PUBLISHED; if (Array.isArray(survey.statusList)) { survey.statusList.push(subCurStatus); } else { @@ -100,39 +96,43 @@ export class SurveyMetaService { return this.surveyRepository.save(survey); } - async editSurveyMeta(survey: SurveyMeta) { - if ( - survey.curStatus.status !== RECORD_STATUS.NEW && - survey.subStatus.status !== RECORD_SUB_STATUS.EDITING - ) { + async editSurveyMeta({ + survey, + operator, + operatorId, + }: { + survey: SurveyMeta; + operator: string; + operatorId: string; + }) { + if (survey?.curStatus?.status !== RECORD_STATUS.EDITING) { const newStatus = { - status: RECORD_SUB_STATUS.EDITING, + status: RECORD_STATUS.EDITING, date: Date.now(), }; - survey.subStatus = newStatus; + survey.curStatus = newStatus; survey.statusList.push(newStatus); } + survey.updatedAt = new Date(); + survey.operator = operator; + survey.operatorId = operatorId; return this.surveyRepository.save(survey); } - async deleteSurveyMeta(survey: SurveyMeta) { - if (survey.subStatus.status === RECORD_SUB_STATUS.REMOVED) { - throw new HttpException( - '问卷已删除,不能重复删除', - EXCEPTION_CODE.SURVEY_STATUS_TRANSFORM_ERROR, - ); - } - const newStatusInfo = { - status: RECORD_SUB_STATUS.REMOVED, - date: Date.now(), - }; - survey.subStatus = newStatusInfo; - if (Array.isArray(survey.statusList)) { - survey.statusList.push(newStatusInfo); - } else { - survey.statusList = [newStatusInfo]; - } - return this.surveyRepository.save(survey); + async deleteSurveyMeta({ surveyId, operator, operatorId }) { + return this.surveyRepository.updateOne( + { + _id: new ObjectId(surveyId), + }, + { + $set: { + isDeleted: true, + operator, + operatorId, + deletedAt: new Date(), + }, + }, + ); } async getSurveyMetaList(condition: { @@ -150,10 +150,9 @@ export class SurveyMetaService { const skip = (pageNum - 1) * pageSize; try { const query: Record = Object.assign( - {}, { - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }, condition.filter, @@ -188,25 +187,13 @@ export class SurveyMetaService { condition.order && Object.keys(condition.order).length > 0 ? (condition.order as FindOptionsOrder) : ({ - createDate: -1, + createdAt: -1, } as FindOptionsOrder); const [data, count] = await this.surveyRepository.findAndCount({ where: query, skip, take: pageSize, order, - select: [ - '_id', - 'title', - 'remark', - 'surveyType', - 'curStatus', - 'subStatus', - 'createDate', - 'owner', - 'ownerId', - 'workspaceId', - ], }); return { data, count }; } catch (error) { @@ -235,8 +222,8 @@ export class SurveyMetaService { async countSurveyMetaByWorkspaceId({ workspaceId }) { const total = await this.surveyRepository.count({ workspaceId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }); return total; diff --git a/server/src/modules/survey/survey.module.ts b/server/src/modules/survey/survey.module.ts index a5c58068..5cd48d89 100644 --- a/server/src/modules/survey/survey.module.ts +++ b/server/src/modules/survey/survey.module.ts @@ -7,6 +7,7 @@ import { LoggerProvider } from 'src/logger/logger.provider'; import { SurveyResponseModule } from '../surveyResponse/surveyResponse.module'; import { AuthModule } from '../auth/auth.module'; import { WorkspaceModule } from '../workspace/workspace.module'; +import { FileModule } from '../file/file.module'; import { DataStatisticController } from './controllers/dataStatistic.controller'; import { SurveyController } from './controllers/survey.controller'; @@ -14,6 +15,8 @@ import { SurveyHistoryController } from './controllers/surveyHistory.controller' import { SurveyMetaController } from './controllers/surveyMeta.controller'; import { SurveyUIController } from './controllers/surveyUI.controller'; import { CollaboratorController } from './controllers/collaborator.controller'; +import { DownloadTaskController } from './controllers/downloadTask.controller'; +import { SessionController } from './controllers/session.controller'; import { SurveyConf } from 'src/models/surveyConf.entity'; import { SurveyHistory } from 'src/models/surveyHistory.entity'; @@ -21,14 +24,21 @@ import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { Word } from 'src/models/word.entity'; import { Collaborator } from 'src/models/collaborator.entity'; -import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; +import { DownloadTask } from 'src/models/downloadTask.entity'; +import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; import { DataStatisticService } from './services/dataStatistic.service'; import { SurveyConfService } from './services/surveyConf.service'; import { SurveyHistoryService } from './services/surveyHistory.service'; import { SurveyMetaService } from './services/surveyMeta.service'; import { ContentSecurityService } from './services/contentSecurity.service'; import { CollaboratorService } from './services/collaborator.service'; +import { Counter } from 'src/models/counter.entity'; +import { CounterService } from '../surveyResponse/services/counter.service'; +import { FileService } from '../file/services/file.service'; +import { DownloadTaskService } from './services/downloadTask.service'; +import { SessionService } from './services/session.service'; +import { Session } from 'src/models/session.entity'; @Module({ imports: [ @@ -39,11 +49,15 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyResponse, Word, Collaborator, + Counter, + DownloadTask, + Session, ]), ConfigModule, SurveyResponseModule, AuthModule, WorkspaceModule, + FileModule, ], controllers: [ DataStatisticController, @@ -52,6 +66,8 @@ import { CollaboratorService } from './services/collaborator.service'; SurveyMetaController, SurveyUIController, CollaboratorController, + DownloadTaskController, + SessionController, ], providers: [ DataStatisticService, @@ -62,6 +78,10 @@ import { CollaboratorService } from './services/collaborator.service'; ContentSecurityService, CollaboratorService, LoggerProvider, + CounterService, + DownloadTaskService, + FileService, + SessionService, ], }) export class SurveyModule {} diff --git a/server/src/modules/survey/template/surveyTemplate/survey/normal.json b/server/src/modules/survey/template/surveyTemplate/survey/normal.json index 606c6e08..5d940a75 100644 --- a/server/src/modules/survey/template/surveyTemplate/survey/normal.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/normal.json @@ -43,7 +43,6 @@ "options": [ { "text": "选项1", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", @@ -52,7 +51,6 @@ }, { "text": "选项2", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", diff --git a/server/src/modules/survey/template/surveyTemplate/survey/register.json b/server/src/modules/survey/template/surveyTemplate/survey/register.json index d7fa4a1b..a5e502d2 100644 --- a/server/src/modules/survey/template/surveyTemplate/survey/register.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/register.json @@ -47,7 +47,6 @@ { "text": "课程1", "hash": "115019", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", @@ -56,7 +55,6 @@ { "text": "课程2", "hash": "115020", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", @@ -65,7 +63,6 @@ { "text": "课程3", "hash": "115021", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", @@ -74,7 +71,6 @@ { "text": "课程4", "hash": "115022", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", diff --git a/server/src/modules/survey/template/surveyTemplate/survey/vote.json b/server/src/modules/survey/template/surveyTemplate/survey/vote.json index f8bbc899..4187c6d2 100644 --- a/server/src/modules/survey/template/surveyTemplate/survey/vote.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/vote.json @@ -41,12 +41,11 @@ "innerType": "radio", "field": "data606", "title": "标题2", - "minNum": "", - "maxNum": "", + "minNum": 0, + "maxNum": 0, "options": [ { "text": "选项1", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", @@ -55,7 +54,6 @@ }, { "text": "选项2", - "imageUrl": "", "others": false, "mustOthers": false, "othersKey": "", diff --git a/server/src/modules/survey/template/surveyTemplate/templateBase.json b/server/src/modules/survey/template/surveyTemplate/templateBase.json index fd3e76e9..bbe43911 100644 --- a/server/src/modules/survey/template/surveyTemplate/templateBase.json +++ b/server/src/modules/survey/template/surveyTemplate/templateBase.json @@ -2,10 +2,12 @@ "bannerConf": { "titleConfig": { "mainTitle": "

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

", - "subTitle": "" + "subTitle": "

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

" }, "bannerConfig": { "bgImage": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp", + "bgImageAllowJump": false, + "bgImageJumpLink": "", "videoLink": "", "postImg": "" } @@ -22,25 +24,35 @@ "msg_9002": "请勿多次提交!", "msg_9003": "您来晚了,已经满额!", "msg_9004": "提交失败!" - } + }, + "link": "" }, "bottomConf": { "logoImage": "/imgs/Logo.webp", "logoImageWidth": "60%" }, "baseConf": { - "begTime": "2024-01-01 00:00:00", + "beginTime": "2024-01-01 00:00:00", "endTime": "2034-01-01 00:00:00", "tLimit": 0, "language": "chinese", "answerBegTime": "00:00:00", - "answerEndTime": "23:59:59" + "answerEndTime": "23:59:59", + "passwordSwitch": false, + "password": "", + "whitelistType": "ALL", + "whitelist": [], + "memberType": "MOBILE", + "fillAnswer": false, + "fillSubmitAnswer": false }, "skinConf": { "skinColor": "#4a4c5b", "inputBgColor": "#ffffff", "backgroundConf": { - "color": "#ffffff" + "color": "#b8dbff", + "type": "color", + "image": "" }, "themeConf": { "color": "#ffa600" @@ -51,6 +63,7 @@ }, "pageConf": [], "logicConf": { - "showLogicConf": [] + "showLogicConf": [], + "jumpLogicConf": [] } } diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index a6a64ccd..286f0430 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -22,7 +22,7 @@ export async function getSchemaBySurveyType(surveyType: string) { } const code = Object.assign({}, templateBase, codeData); const nowMoment = moment(); - code.baseConf.begTime = nowMoment.format('YYYY-MM-DD HH:mm:ss'); + code.baseConf.beginTime = nowMoment.format('YYYY-MM-DD HH:mm:ss'); code.baseConf.endTime = nowMoment .add(10, 'years') .format('YYYY-MM-DD HH:mm:ss'); @@ -63,7 +63,7 @@ export function getListHeadByDataList(dataList) { type: QUESTION_TYPE.TEXT, }); listHead.push({ - field: 'createDate', + field: 'createdAt', title: '提交时间', type: QUESTION_TYPE.TEXT, }); diff --git a/server/src/modules/surveyResponse/__test/clientEncrypt.service.spec.ts b/server/src/modules/surveyResponse/__test/clientEncrypt.service.spec.ts index ff8a941f..5dfc0919 100644 --- a/server/src/modules/surveyResponse/__test/clientEncrypt.service.spec.ts +++ b/server/src/modules/surveyResponse/__test/clientEncrypt.service.spec.ts @@ -3,7 +3,6 @@ import { MongoRepository } from 'typeorm'; import { ClientEncryptService } from '../services/clientEncrypt.service'; import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; import { ENCRYPT_TYPE } from 'src/enums/encrypt'; -import { RECORD_SUB_STATUS } from 'src/enums'; import { ObjectId } from 'mongodb'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -88,9 +87,6 @@ describe('ClientEncryptService', () => { expect(repository.findOne).toHaveBeenCalledWith({ where: { _id: new ObjectId(id), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, }); expect(result).toEqual(encryptInfo); diff --git a/server/src/modules/surveyResponse/__test/mockResponseSchema.ts b/server/src/modules/surveyResponse/__test/mockResponseSchema.ts index d07c3fdd..a6d98c7f 100644 --- a/server/src/modules/surveyResponse/__test/mockResponseSchema.ts +++ b/server/src/modules/surveyResponse/__test/mockResponseSchema.ts @@ -25,8 +25,7 @@ export const mockResponseSchema: ResponseSchema = { code: { bannerConf: { titleConfig: { - mainTitle: - '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', + mainTitle: '

欢迎填写问卷

为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,期待您的参与!

', subTitle: '', }, bannerConfig: { @@ -36,7 +35,7 @@ export const mockResponseSchema: ResponseSchema = { }, }, baseConf: { - begTime: '2024-03-14 14:54:41', + beginTime: '2024-03-14 14:54:41', endTime: '2034-03-14 14:54:41', language: 'chinese', tLimit: 10, @@ -48,6 +47,17 @@ export const mockResponseSchema: ResponseSchema = { logoImageWidth: '60%', }, skinConf: { + backgroundConf: { + color: '#fff', + type: 'color', + image: '', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, skinColor: '#4a4c5b', inputBgColor: '#ffffff', }, @@ -240,4 +250,4 @@ export const mockResponseSchema: ResponseSchema = { }, }, pageId: '65f29f3192862d6a9067ad1c', -} as ResponseSchema; +} as unknown as ResponseSchema; diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index 55a81096..e62a1253 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -13,7 +13,7 @@ import { ClientEncryptService } from '../services/clientEncrypt.service'; import { MessagePushingTaskService } from 'src/modules/message/services/messagePushingTask.service'; import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; -import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; +import { PluginManager } from 'src/securityPlugin/pluginManager'; import { HttpException } from 'src/exceptions/httpException'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; @@ -153,9 +153,7 @@ describe('SurveyResponseController', () => { clientEncryptService = module.get(ClientEncryptService); - const pluginManager = module.get( - XiaojuSurveyPluginManager, - ); + const pluginManager = module.get(PluginManager); pluginManager.registerPlugin( new ResponseSecurityPlugin('dataAesEncryptSecretKey'), ); @@ -220,7 +218,8 @@ describe('SurveyResponseController', () => { jest .spyOn(clientEncryptService, 'deleteEncryptInfo') .mockResolvedValueOnce(undefined); - const result = await controller.createResponse(reqBody, {}); + + const result = await controller.createResponse(reqBody); expect(result).toEqual({ code: 200, msg: '提交成功' }); expect( @@ -267,7 +266,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(null); - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( SurveyNotFoundException, ); }); @@ -276,7 +275,7 @@ describe('SurveyResponseController', () => { const reqBody = cloneDeep(mockSubmitData); delete reqBody.sign; - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( HttpException, ); @@ -289,7 +288,7 @@ describe('SurveyResponseController', () => { const reqBody = cloneDeep(mockDecryptErrorBody); reqBody.sign = 'mock sign'; - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( HttpException, ); @@ -305,7 +304,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(mockResponseSchema); - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( HttpException, ); }); @@ -317,7 +316,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(mockResponseSchema); - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( HttpException, ); }); @@ -346,7 +345,7 @@ describe('SurveyResponseController', () => { }, } as ResponseSchema); - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( new HttpException('白名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR), ); }); diff --git a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts index 13937f66..9f2ce285 100644 --- a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts +++ b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts @@ -44,16 +44,20 @@ export class ResponseSchemaController { await this.responseSchemaService.getResponseSchemaByPath( queryInfo.surveyPath, ); - if ( - !responseSchema || - responseSchema.subStatus.status === RECORD_SUB_STATUS.REMOVED - ) { + if (!responseSchema || responseSchema.isDeleted) { throw new HttpException( - '问卷已删除', + '问卷不存在或已删除', EXCEPTION_CODE.RESPONSE_SCHEMA_REMOVED, ); } + if (responseSchema.subStatus.status === RECORD_SUB_STATUS.PAUSING) { + throw new HttpException( + '该问卷已暂停回收', + EXCEPTION_CODE.RESPONSE_PAUSING, + ); + } + // 去掉C端的敏感字段 if (responseSchema.code?.baseConf) { responseSchema.code.baseConf.password = null; @@ -82,7 +86,7 @@ export class ResponseSchemaController { // 问卷信息 const schema = await this.responseSchemaService.getResponseSchemaByPath(surveyPath); - if (!schema || schema.subStatus.status === RECORD_SUB_STATUS.REMOVED) { + if (!schema || schema.isDeleted) { throw new SurveyNotFoundException('该问卷不存在,无法提交'); } @@ -97,14 +101,17 @@ export class ResponseSchemaController { // 密码校验 if (passwordSwitch) { if (settingPassword !== password) { - throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + throw new HttpException('密码验证失败', EXCEPTION_CODE.WHITELIST_ERROR); } } // 名单校验(手机号/邮箱) if (whitelistType === WhitelistType.CUSTOM) { if (!whitelist.includes(whitelistValue)) { - throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + throw new HttpException( + '白名单验证失败', + EXCEPTION_CODE.WHITELIST_ERROR, + ); } } @@ -112,7 +119,7 @@ export class ResponseSchemaController { if (whitelistType === WhitelistType.MEMBER) { const user = await this.userService.getUserByUsername(whitelistValue); if (!user) { - throw new HttpException('验证失败', EXCEPTION_CODE.WHITELIST_ERROR); + throw new HttpException('名单验证失败', EXCEPTION_CODE.WHITELIST_ERROR); } const workspaceMember = await this.workspaceMemberService.findAllByUserId( diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts index 03600d40..18621967 100644 --- a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts +++ b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body, HttpCode, Request } from '@nestjs/common'; +import { Controller, Post, Body, HttpCode } from '@nestjs/common'; import { HttpException } from 'src/exceptions/httpException'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { checkSign } from 'src/utils/checkSign'; @@ -8,37 +8,48 @@ import { getPushingData } from 'src/utils/messagePushing'; import { RECORD_SUB_STATUS } from 'src/enums'; import { ResponseSchemaService } from '../services/responseScheme.service'; -import { CounterService } from '../services/counter.service'; import { SurveyResponseService } from '../services/surveyResponse.service'; import { ClientEncryptService } from '../services/clientEncrypt.service'; import { MessagePushingTaskService } from '../../message/services/messagePushingTask.service'; +// import { RedisService } from 'src/modules/redis/redis.service'; import moment from 'moment'; import * as Joi from 'joi'; import * as forge from 'node-forge'; import { ApiTags } from '@nestjs/swagger'; + +import { CounterService } from '../services/counter.service'; import { Logger } from 'src/logger'; import { WhitelistType } from 'src/interfaces/survey'; import { UserService } from 'src/modules/auth/services/user.service'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { QUESTION_TYPE } from 'src/enums/question'; + +const optionQuestionType: Array = [ + QUESTION_TYPE.RADIO, + QUESTION_TYPE.CHECKBOX, + QUESTION_TYPE.BINARY_CHOICE, + QUESTION_TYPE.VOTE, +]; @ApiTags('surveyResponse') @Controller('/api/surveyResponse') export class SurveyResponseController { constructor( private readonly responseSchemaService: ResponseSchemaService, - private readonly counterService: CounterService, private readonly surveyResponseService: SurveyResponseService, private readonly clientEncryptService: ClientEncryptService, private readonly messagePushingTaskService: MessagePushingTaskService, + private readonly counterService: CounterService, private readonly logger: Logger, + // private readonly redisService: RedisService, private readonly userService: UserService, private readonly workspaceMemberService: WorkspaceMemberService, ) {} @Post('/createResponse') @HttpCode(200) - async createResponse(@Body() reqBody, @Request() req) { + async createResponse(@Body() reqBody) { // 检查签名 checkSign(reqBody); // 校验参数 @@ -54,9 +65,7 @@ export class SurveyResponseController { }).validate(reqBody, { allowUnknown: true }); if (error) { - this.logger.error(`updateMeta_parameter error: ${error.message}`, { - req, - }); + this.logger.error(`updateMeta_parameter error: ${error.message}`); throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); } @@ -74,10 +83,7 @@ export class SurveyResponseController { // 查询schema const responseSchema = await this.responseSchemaService.getResponseSchemaByPath(surveyPath); - if ( - !responseSchema || - responseSchema.subStatus.status === RECORD_SUB_STATUS.REMOVED - ) { + if (!responseSchema || responseSchema.isDeleted) { throw new SurveyNotFoundException('该问卷不存在,无法提交'); } if (responseSchema?.subStatus?.status === RECORD_SUB_STATUS.PAUSING) { @@ -133,12 +139,12 @@ export class SurveyResponseController { const now = Date.now(); // 提交时间限制 - const begTime = responseSchema.code?.baseConf?.begTime || 0; + const beginTime = responseSchema.code?.baseConf?.beginTime || 0; const endTime = responseSchema?.code?.baseConf?.endTime || 0; - if (begTime && endTime) { - const begTimeStamp = new Date(begTime).getTime(); + if (beginTime && endTime) { + const beginTimeStamp = new Date(beginTime).getTime(); const endTimeStamp = new Date(endTime).getTime(); - if (now < begTimeStamp || now > endTimeStamp) { + if (now < beginTimeStamp || now > endTimeStamp) { throw new HttpException( '不在答题有效期内', EXCEPTION_CODE.RESPONSE_CURRENT_TIME_NOT_ALLOW, @@ -215,6 +221,7 @@ export class SurveyResponseController { const optionTextAndId = dataList .filter((questionItem) => { return ( + optionQuestionType.includes(questionItem.type) && Array.isArray(questionItem.options) && questionItem.options.length > 0 && decryptedData[questionItem.field] @@ -229,34 +236,55 @@ export class SurveyResponseController { return pre; }, {}); - // 对用户提交的数据进行遍历处理 - for (const field in decryptedData) { - const val = decryptedData[field]; - const vals = Array.isArray(val) ? val : [val]; - if (field in optionTextAndId) { - // 记录选项的提交数量,用于投票题回显、或者拓展上限限制功能 - const optionCountData: Record = - (await this.counterService.get({ - surveyPath, - key: field, - type: 'option', - })) || { total: 0 }; - optionCountData.total++; - for (const val of vals) { - if (!optionCountData[val]) { - optionCountData[val] = 1; - } else { + const surveyId = responseSchema.pageId; + // const lockKey = `locks:optionSelectedCount:${surveyId}`; + // const lock = await this.redisService.lockResource(lockKey, 1000); + // this.logger.info(`lockKey: ${lockKey}`); + try { + const successParams = []; + for (const field in decryptedData) { + const value = decryptedData[field]; + const values = Array.isArray(value) ? value : [value]; + if (field in optionTextAndId) { + const optionCountData = + (await this.counterService.get({ + key: field, + surveyPath, + type: 'option', + })) || {}; + + //遍历选项hash值 + for (const val of values) { + if (!optionCountData[val]) { + optionCountData[val] = 0; + } optionCountData[val]++; } + if (!optionCountData['total']) { + optionCountData['total'] = 1; + } else { + optionCountData['total']++; + } + successParams.push({ + key: field, + surveyPath, + type: 'option', + data: optionCountData, + }); } - this.counterService.set({ - surveyPath, - key: field, - data: optionCountData, - type: 'option', - }); } + // 校验通过后统一更新 + await Promise.all( + successParams.map((item) => this.counterService.set(item)), + ); + } catch (error) { + this.logger.error(error.message); + throw error; } + // finally { + // await this.redisService.unlockResource(lock); + // this.logger.info(`unlockResource: ${lockKey}`); + // } // 入库 const surveyResponse = @@ -269,7 +297,6 @@ export class SurveyResponseController { optionTextAndId, }); - const surveyId = responseSchema.pageId; const sendData = getPushingData({ surveyResponse, questionList: responseSchema?.code?.dataConf?.dataList || [], diff --git a/server/src/modules/surveyResponse/services/clientEncrypt.service.ts b/server/src/modules/surveyResponse/services/clientEncrypt.service.ts index de6883d4..e679bda8 100644 --- a/server/src/modules/surveyResponse/services/clientEncrypt.service.ts +++ b/server/src/modules/surveyResponse/services/clientEncrypt.service.ts @@ -4,7 +4,6 @@ import { MongoRepository } from 'typeorm'; import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; import { ENCRYPT_TYPE } from 'src/enums/encrypt'; import { ObjectId } from 'mongodb'; -import { RECORD_SUB_STATUS } from 'src/enums'; @Injectable() export class ClientEncryptService { @@ -38,26 +37,13 @@ export class ClientEncryptService { return this.clientEncryptRepository.findOne({ where: { _id: new ObjectId(id), - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, }); } deleteEncryptInfo(id: string) { - return this.clientEncryptRepository.updateOne( - { - _id: new ObjectId(id), - }, - { - $set: { - subStatus: { - status: RECORD_SUB_STATUS.REMOVED, - date: Date.now(), - }, - }, - }, - ); + return this.clientEncryptRepository.deleteOne({ + _id: new ObjectId(id), + }); } } diff --git a/server/src/modules/surveyResponse/services/counter.service.ts b/server/src/modules/surveyResponse/services/counter.service.ts index 0b72ab82..d6581e49 100644 --- a/server/src/modules/surveyResponse/services/counter.service.ts +++ b/server/src/modules/surveyResponse/services/counter.service.ts @@ -33,6 +33,7 @@ export class CounterService { surveyPath, type, data, + updatedAt: new Date(), }, }, { diff --git a/server/src/modules/surveyResponse/services/responseScheme.service.ts b/server/src/modules/surveyResponse/services/responseScheme.service.ts index 41572438..b1dc793c 100644 --- a/server/src/modules/surveyResponse/services/responseScheme.service.ts +++ b/server/src/modules/surveyResponse/services/responseScheme.service.ts @@ -43,7 +43,6 @@ export class ResponseSchemaService { code, pageId, curStatus, - statusList: [curStatus], subStatus, }); return this.responseSchemaRepository.save(newClientSurvey); @@ -73,31 +72,21 @@ export class ResponseSchemaService { }; responseSchema.subStatus = subStatus; responseSchema.curStatus.status = RECORD_STATUS.PUBLISHED; - if (Array.isArray(responseSchema.statusList)) { - responseSchema.statusList.push(subStatus); - } else { - responseSchema.statusList = [subStatus]; - } return this.responseSchemaRepository.save(responseSchema); } } async deleteResponseSchema({ surveyPath }) { - const responseSchema = await this.responseSchemaRepository.findOne({ - where: { surveyPath }, - }); - if (responseSchema) { - const newStatus = { - status: RECORD_STATUS.PUBLISHED, - date: Date.now(), - }; - responseSchema.curStatus = newStatus; - if (Array.isArray(responseSchema.statusList)) { - responseSchema.statusList.push(newStatus); - } else { - responseSchema.statusList = [newStatus]; - } - return this.responseSchemaRepository.save(responseSchema); - } + return this.responseSchemaRepository.updateOne( + { + surveyPath, + }, + { + $set: { + isDeleted: true, + updatedAt: new Date(), + }, + }, + ); } } diff --git a/server/src/modules/surveyResponse/services/surveyResponse.service.ts b/server/src/modules/surveyResponse/services/surveyResponse.service.ts index f0ad2790..90f4dd7e 100644 --- a/server/src/modules/surveyResponse/services/surveyResponse.service.ts +++ b/server/src/modules/surveyResponse/services/surveyResponse.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MongoRepository } from 'typeorm'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; -import { RECORD_SUB_STATUS } from 'src/enums'; @Injectable() export class SurveyResponseService { constructor( @@ -36,14 +35,11 @@ export class SurveyResponseService { } async getSurveyResponseTotalByPath(surveyPath: string) { - const count = await this.surveyResponseRepository.count({ + const data = await this.surveyResponseRepository.find({ where: { surveyPath, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, }); - return count; + return (data || []).length; } } diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index faf1a86d..2bcd0b71 100644 --- a/server/src/modules/surveyResponse/surveyResponse.module.ts +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -1,19 +1,18 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule } from '@nestjs/config'; - import { MessageModule } from '../message/message.module'; +// import { RedisModule } from '../redis/redis.module'; import { ResponseSchemaService } from './services/responseScheme.service'; import { SurveyResponseService } from './services/surveyResponse.service'; import { CounterService } from './services/counter.service'; import { ClientEncryptService } from './services/clientEncrypt.service'; +// import { RedisService } from '../redis/redis.service'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { Counter } from 'src/models/counter.entity'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; -import { Logger } from 'src/logger'; +import { LoggerProvider } from 'src/logger/logger.provider'; import { ClientEncryptController } from './controllers/clientEncrpt.controller'; import { CounterController } from './controllers/counter.controller'; @@ -23,6 +22,9 @@ import { SurveyResponseUIController } from './controllers/surveyResponseUI.contr import { AuthModule } from '../auth/auth.module'; import { WorkspaceModule } from '../workspace/workspace.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + @Module({ imports: [ TypeOrmModule.forFeature([ @@ -33,6 +35,7 @@ import { WorkspaceModule } from '../workspace/workspace.module'; ]), ConfigModule, MessageModule, + // RedisModule, AuthModule, WorkspaceModule, ], @@ -48,7 +51,8 @@ import { WorkspaceModule } from '../workspace/workspace.module'; SurveyResponseService, CounterService, ClientEncryptService, - Logger, + LoggerProvider, + // RedisService, ], exports: [ ResponseSchemaService, diff --git a/server/src/modules/upgrade/controllers/upgrade.controller.ts b/server/src/modules/upgrade/controllers/upgrade.controller.ts index 0fb3f5d3..b2c6f4f6 100644 --- a/server/src/modules/upgrade/controllers/upgrade.controller.ts +++ b/server/src/modules/upgrade/controllers/upgrade.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode } from '@nestjs/common'; +import { Controller, Get, HttpCode, Request } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { UpgradeService } from '../services/upgrade.service'; @@ -7,12 +7,15 @@ import { UpgradeService } from '../services/upgrade.service'; export class UpgradeController { constructor(private readonly upgradeService: UpgradeService) {} - @Get('/subStatus') + @Get('/upgradeFeatureStatus') @HttpCode(200) - async upgradeSubStatus() { - await this.upgradeService.upgradeSubStatus(); + async upgradeSubStatus(@Request() req) { + this.upgradeService.upgradeFeatureStatus(); return { code: 200, + data: { + traceId: req.traceId, + }, }; } } diff --git a/server/src/modules/upgrade/services/upgrade.service.ts b/server/src/modules/upgrade/services/upgrade.service.ts index 45998caa..1eaa21f2 100644 --- a/server/src/modules/upgrade/services/upgrade.service.ts +++ b/server/src/modules/upgrade/services/upgrade.service.ts @@ -2,73 +2,201 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MongoRepository } from 'typeorm'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; -import { ResponseSchema } from 'src/models/ResponseSchema.entity'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums'; +import { Workspace } from 'src/models/workspace.entity'; +import { Collaborator } from 'src/models/collaborator.entity'; +import { Counter } from 'src/models/counter.entity'; +import { DownloadTask } from 'src/models/downloadTask.entity'; +import { MessagePushingLog } from 'src/models/messagePushingLog.entity'; +import { MessagePushingTask } from 'src/models/messagePushingTask.entity'; +import { Session } from 'src/models/session.entity'; +import { SurveyConf } from 'src/models/surveyConf.entity'; +import { User } from 'src/models/user.entity'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; +import { SESSION_STATUS } from 'src/enums/surveySessionStatus'; +import { Logger } from 'src/logger'; @Injectable() export class UpgradeService { constructor( - @InjectRepository(SurveyMeta) - private readonly SurveyMeta: MongoRepository, + private readonly logger: Logger, + @InjectRepository(Collaborator) + private readonly collaboratorRepository: MongoRepository, + @InjectRepository(Counter) + private readonly counterRepository: MongoRepository, + @InjectRepository(DownloadTask) + private readonly downloadTaskRepository: MongoRepository, + @InjectRepository(MessagePushingLog) + private readonly messagePushingLogRepository: MongoRepository, + @InjectRepository(MessagePushingTask) + private readonly messagePushingTaskRepository: MongoRepository, @InjectRepository(ResponseSchema) - private readonly ResponseSchema: MongoRepository, + private readonly responseSchemaRepository: MongoRepository, + @InjectRepository(Session) + private readonly sessionRepository: MongoRepository, + @InjectRepository(SurveyConf) + private readonly surveyConfRepository: MongoRepository, + @InjectRepository(SurveyMeta) + private readonly surveyMetaRepository: MongoRepository, + @InjectRepository(User) + private readonly userRepository: MongoRepository, + @InjectRepository(Workspace) + private readonly workspaceRepository: MongoRepository, + @InjectRepository(WorkspaceMember) + private readonly workspaceMemberRepository: MongoRepository, ) {} - async upgradeSubStatus() { - const surveyMetaList = await this.SurveyMeta.find(); - const responseSchemaList = await this.ResponseSchema.find(); - - const callBack = (v: SurveyMeta | ResponseSchema) => { - // 将主状态的REMOVED,EDITING刷到子状态 - // 主状态查一下历史数据删除前最近的状态是“新建”or“已发布 - if ( - v.curStatus.status == (RECORD_SUB_STATUS.REMOVED as any) || - v.curStatus.status == (RECORD_SUB_STATUS.EDITING as any) - ) { - const subStatus = { - status: v.curStatus.status, - date: v.curStatus.date, - }; - v.subStatus = subStatus as any; - console.log('subStatus', subStatus); - if (v.curStatus.status == (RECORD_SUB_STATUS.EDITING as any)) { - v.curStatus.status = RECORD_STATUS.PUBLISHED; + async upgradeFeatureStatus() { + const repositories = [ + this.collaboratorRepository, + this.counterRepository, + this.downloadTaskRepository, + this.messagePushingLogRepository, + this.messagePushingTaskRepository, + this.responseSchemaRepository, + this.sessionRepository, + this.surveyConfRepository, + this.surveyMetaRepository, + this.userRepository, + this.workspaceRepository, + this.workspaceMemberRepository, + ]; + const handleCreatedAtAndUpdatedAt = (doc) => { + if (!doc.createdAt) { + if (doc.createDate) { + doc.createdAt = new Date(doc.createDate); + delete doc.createDate; + } else { + doc.createdAt = new Date(); } - if (v.curStatus.status == (RECORD_SUB_STATUS.REMOVED as any)) { - for (let index = v.statusList.length; index > 0; index--) { - const item = v.statusList[index]; - if ( - item?.status == RECORD_STATUS.PUBLISHED || - item?.status == RECORD_STATUS.NEW - ) { - v.curStatus.status = item.status; - break; - } - } - } - return v; } + if (!doc.updatedAt) { + if (doc.updateDate) { + doc.updatedAt = new Date(doc.updateDate); + delete doc.updateDate; + } else { + doc.createdAt = new Date(); + } + } + }; + + const handleDelStatus = (doc) => { + // 已删除的字段升级 + if (doc?.curStatus?.status === 'removed') { + delete doc.curStatus; + doc.isDeleted = true; + doc.deletedAt = new Date(doc.updatedAt); + } + }; + + const handleSubStatus = (doc) => { + // 编辑中字段升级 if ( - v.curStatus.status == RECORD_STATUS.PUBLISHED || - v.curStatus.status == RECORD_STATUS.NEW + !doc?.subStatus && + (doc?.curStatus?.status == RECORD_STATUS.PUBLISHED || + doc?.curStatus?.status == RECORD_STATUS.NEW || + doc?.curStatus?.status === RECORD_STATUS.EDITING) ) { const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, - date: v.statusList[0].date, + date: doc.curStatus.date, }; - v.subStatus = subStatus; + doc.subStatus = subStatus; } - return v; }; - surveyMetaList.map(async (v) => { - const item = callBack(v); - await this.SurveyMeta.save(item); - }); + const handleBegTime = (doc) => { + if (!doc?.baseConf?.beginTime && doc?.baseConf?.begTime) { + doc.baseConf.beginTime = doc.baseConf.begTime; + delete doc.baseConf.begTime; + } + }; - responseSchemaList.map(async (v) => { - const item = callBack(v); - await this.ResponseSchema.save(item); - }); + const handleSessionStatus = (doc) => { + if (!doc.status && doc.curStatus) { + if (doc?.curStatus?.id && doc?.curStatus?.id === 'editing') { + doc.status = SESSION_STATUS.ACTIVATED; + } else { + doc.status = SESSION_STATUS.DEACTIVATED; + } + delete doc.curStatus; + } + }; + + const handleCreatorId = async (doc) => { + if (!doc.ownerId && doc.owner) { + const userInfo = await this.userRepository.findOne({ + where: { + username: doc.owner, + }, + }); + if (userInfo && userInfo._id) { + doc.ownerId = userInfo._id.toString(); + } + } + if (doc.ownerId && doc.owner && !doc.creatorId) { + doc.creatorId = doc.ownerId; + doc.creator = doc.owner; + } + }; + + const save = async ({ doc, repository }) => { + const entity = repository.create(doc); + await repository.save(entity); + }; + this.logger.info(`upgrading...`); + for (const repository of repositories) { + const name = + typeof repository.target === 'function' + ? repository.target.name + : typeof repository.target === 'string' + ? repository.target + : ''; + + const cursor = repository.createCursor(); + this.logger.info(`upgrading ${name}`); + while (await cursor.hasNext()) { + try { + const doc = await cursor.next(); + // 把createDate和updateDate升级成createdAt和updatedAt + handleCreatedAtAndUpdatedAt(doc); + if ( + repository === this.surveyMetaRepository || + repository === this.responseSchemaRepository + ) { + // 新增subStatus字段 + handleSubStatus(doc); + } + if ( + repository === this.surveyMetaRepository || + repository === this.downloadTaskRepository || + repository === this.messagePushingTaskRepository || + repository === this.workspaceRepository || + repository === this.responseSchemaRepository + ) { + // 新增isDeleted等相关字段 + handleDelStatus(doc); + } + // 同步sessionStatus到新定义的字段 + if (repository === this.sessionRepository) { + handleSessionStatus(doc); + } + // 同步begTime,更新成beginTime + if (repository === this.surveyConfRepository) { + handleBegTime(doc); + } + // 同步ownerId到creatorId + if (repository === this.surveyMetaRepository) { + await handleCreatorId(doc); + } + await save({ repository, doc }); + } catch (error) { + this.logger.error(`upgrade ${name} error ${error.message}`); + } + } + this.logger.info(`finish upgrade ${name}`); + } + this.logger.info(`upgrad finished...`); } } diff --git a/server/src/modules/upgrade/upgrade.module.ts b/server/src/modules/upgrade/upgrade.module.ts index b183d375..37f36e08 100644 --- a/server/src/modules/upgrade/upgrade.module.ts +++ b/server/src/modules/upgrade/upgrade.module.ts @@ -1,36 +1,48 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; - -import { MessageModule } from '../message/message.module'; - import { UpgradeService } from './services/upgrade.service'; -import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { Collaborator } from 'src/models/collaborator.entity'; import { Counter } from 'src/models/counter.entity'; -import { SurveyResponse } from 'src/models/surveyResponse.entity'; -import { ClientEncrypt } from 'src/models/clientEncrypt.entity'; -import { Logger } from 'src/logger'; +import { DownloadTask } from 'src/models/downloadTask.entity'; +import { MessagePushingLog } from 'src/models/messagePushingLog.entity'; +import { MessagePushingTask } from 'src/models/messagePushingTask.entity'; +import { ResponseSchema } from 'src/models/responseSchema.entity'; +import { Session } from 'src/models/session.entity'; +import { SurveyConf } from 'src/models/surveyConf.entity'; +import { SurveyHistory } from 'src/models/surveyHistory.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; +import { SurveyResponse } from 'src/models/surveyResponse.entity'; +import { User } from 'src/models/user.entity'; +import { Workspace } from 'src/models/workspace.entity'; +import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { UpgradeController } from './controllers/upgrade.controller'; - import { AuthModule } from '../auth/auth.module'; -import { WorkspaceModule } from '../workspace/workspace.module'; + +import { Logger } from 'src/logger'; @Module({ imports: [ TypeOrmModule.forFeature([ - ResponseSchema, + Collaborator, Counter, - SurveyResponse, - ClientEncrypt, + DownloadTask, + MessagePushingLog, + MessagePushingTask, + ResponseSchema, + Session, + SurveyConf, + SurveyHistory, SurveyMeta, + SurveyResponse, + User, + Workspace, + WorkspaceMember, ]), ConfigModule, - MessageModule, AuthModule, - WorkspaceModule, ], controllers: [UpgradeController], providers: [UpgradeService, Logger], diff --git a/server/src/modules/workspace/controllers/workspace.controller.ts b/server/src/modules/workspace/controllers/workspace.controller.ts index 75da6319..276577f5 100644 --- a/server/src/modules/workspace/controllers/workspace.controller.ts +++ b/server/src/modules/workspace/controllers/workspace.controller.ts @@ -64,10 +64,7 @@ export class WorkspaceController { async create(@Body() workspace: CreateWorkspaceDto, @Request() req) { const { value, error } = CreateWorkspaceDto.validate(workspace); if (error) { - this.logger.error( - `CreateWorkspaceDto validate failed: ${error.message}`, - { req }, - ); + this.logger.error(`CreateWorkspaceDto validate failed: ${error.message}`); throw new HttpException( `参数错误: 请联系管理员`, EXCEPTION_CODE.PARAMETER_ERROR, @@ -103,10 +100,12 @@ export class WorkspaceController { } } const userId = req.user._id.toString(); + const username = req.user.username; // 插入空间表 const retWorkspace = await this.workspaceService.create({ name: value.name, description: value.description, + owner: username, ownerId: userId, }); const workspaceId = retWorkspace._id.toString(); @@ -120,6 +119,8 @@ export class WorkspaceController { await this.workspaceMemberService.batchCreate({ workspaceId, members: value.members, + creator: username, + creatorId: userId, }); } return { @@ -137,7 +138,6 @@ export class WorkspaceController { if (error) { this.logger.error( `GetWorkspaceListDto validate failed: ${error.message}`, - { req }, ); throw new HttpException( `参数错误: 请联系管理员`, @@ -210,7 +210,7 @@ export class WorkspaceController { const ownerInfo = userInfoMap?.[item.ownerId] || {}; return { ...item, - createDate: moment(item.createDate).format('YYYY-MM-DD HH:mm:ss'), + createdAt: moment(item.createdAt).format('YYYY-MM-DD HH:mm:ss'), owner: ownerInfo.username, currentUserId: curWorkspaceInfo.userId, currentUserRole: curWorkspaceInfo.role, @@ -272,13 +272,24 @@ export class WorkspaceController { @UseGuards(WorkspaceGuard) @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE]) @SetMetadata('workspaceId', 'params.id') - async update(@Param('id') id: string, @Body() workspace: CreateWorkspaceDto) { + async update( + @Param('id') id: string, + @Body() workspace: CreateWorkspaceDto, + @Request() req, + ) { const members = workspace.members; if (!Array.isArray(members) || members.length === 0) { throw new HttpException('成员不能为空', EXCEPTION_CODE.PARAMETER_ERROR); } delete workspace.members; - const updateRes = await this.workspaceService.update(id, workspace); + const operator = req.user.username, + operatorId = req.user._id.toString(); + const updateRes = await this.workspaceService.update({ + id, + workspace, + operator, + operatorId, + }); this.logger.info(`updateRes: ${JSON.stringify(updateRes)}`); const { newMembers, adminMembers, userMembers } = splitMembers(members); if ( @@ -324,14 +335,20 @@ export class WorkspaceController { this.workspaceMemberService.batchCreate({ workspaceId: id, members: newMembers, + creator: operator, + creatorId: operatorId, }), this.workspaceMemberService.batchUpdate({ idList: adminMembers, role: WORKSPACE_ROLE.ADMIN, + operator, + operatorId, }), this.workspaceMemberService.batchUpdate({ idList: userMembers, role: WORKSPACE_ROLE.USER, + operator, + operatorId, }), ]); this.logger.info(`updateRes: ${JSON.stringify(res)}`); @@ -345,8 +362,13 @@ export class WorkspaceController { @UseGuards(WorkspaceGuard) @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE]) @SetMetadata('workspaceId', 'params.id') - async delete(@Param('id') id: string) { - const res = await this.workspaceService.delete(id); + async delete(@Param('id') id: string, @Request() req) { + const operator = req.user.username, + operatorId = req.user._id.toString(); + const res = await this.workspaceService.delete(id, { + operator, + operatorId, + }); this.logger.info(`res: ${JSON.stringify(res)}`); return { code: 200, diff --git a/server/src/modules/workspace/controllers/workspaceMember.controller.ts b/server/src/modules/workspace/controllers/workspaceMember.controller.ts index 5dab6caa..6eac9668 100644 --- a/server/src/modules/workspace/controllers/workspaceMember.controller.ts +++ b/server/src/modules/workspace/controllers/workspaceMember.controller.ts @@ -77,7 +77,10 @@ export class WorkspaceMemberController { @Post('updateRole') @SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_MEMBER]) @SetMetadata('workspaceId', 'body.workspaceId') - async updateRole(@Body() updateDto: UpdateWorkspaceMemberDto) { + async updateRole( + @Body() updateDto: UpdateWorkspaceMemberDto, + @Request() req, + ) { const { error, value } = UpdateWorkspaceMemberDto.validate(updateDto); if (error) { throw new HttpException( @@ -85,10 +88,14 @@ export class WorkspaceMemberController { EXCEPTION_CODE.PARAMETER_ERROR, ); } + const operator = req.user.username, + operatorId = req.user._id.toString(); const updateRes = await this.workspaceMemberService.updateRole({ role: value.role, workspaceId: value.workspaceId, userId: value.userId, + operator, + operatorId, }); return { code: 200, diff --git a/server/src/modules/workspace/services/workspace.service.ts b/server/src/modules/workspace/services/workspace.service.ts index 4c91e666..b5444540 100644 --- a/server/src/modules/workspace/services/workspace.service.ts +++ b/server/src/modules/workspace/services/workspace.service.ts @@ -6,7 +6,6 @@ import { Workspace } from 'src/models/workspace.entity'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { ObjectId } from 'mongodb'; -import { RECORD_SUB_STATUS } from 'src/enums'; interface FindAllByIdWithPaginationParams { workspaceIdList: string[]; @@ -31,10 +30,13 @@ export class WorkspaceService { async create(workspace: { name: string; description: string; + owner: string; ownerId: string; }): Promise { const newWorkspace = this.workspaceRepository.create({ ...workspace, + creatorId: workspace.ownerId, + creator: workspace.owner, }); return this.workspaceRepository.save(newWorkspace); } @@ -56,8 +58,8 @@ export class WorkspaceService { _id: { $in: workspaceIdList.map((item) => new ObjectId(item)), }, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }; @@ -68,11 +70,11 @@ export class WorkspaceService { }, select: [ '_id', - 'curStatus', 'name', 'description', 'ownerId', - 'createDate', + 'creatorId', + 'createdAt', ], }); } @@ -91,8 +93,8 @@ export class WorkspaceService { _id: { $in: workspaceIdList.map((m) => new ObjectId(m)), }, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }; if (name) { @@ -103,31 +105,40 @@ export class WorkspaceService { skip, take: limit, order: { - createDate: -1, + createdAt: -1, }, }); return { list: data, count }; } - update(id: string, workspace: Partial) { + update({ + id, + workspace, + operator, + operatorId, + }: { + id: string; + workspace: Partial; + operator: string; + operatorId: string; + }) { + workspace.updatedAt = new Date(); + workspace.operator = operator; + workspace.operatorId = operatorId; return this.workspaceRepository.update(id, workspace); } - async delete(id: string) { - const newStatus = { - status: RECORD_SUB_STATUS.REMOVED, - date: Date.now(), - }; + async delete(id: string, { operator, operatorId }) { const workspaceRes = await this.workspaceRepository.updateOne( { _id: new ObjectId(id), }, { $set: { - subStatus: newStatus, - }, - $push: { - statusList: newStatus as never, + isDeleted: true, + deletedAt: new Date(), + operator, + operatorId, }, }, ); @@ -137,10 +148,10 @@ export class WorkspaceService { }, { $set: { - subStatus: newStatus, - }, - $push: { - statusList: newStatus as never, + isDeleted: true, + deletedAt: new Date(), + operator, + operatorId, }, }, ); @@ -155,8 +166,8 @@ export class WorkspaceService { return await this.workspaceRepository.find({ where: { ownerId: userId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, + isDeleted: { + $ne: true, }, }, order: { @@ -168,7 +179,7 @@ export class WorkspaceService { 'name', 'description', 'ownerId', - 'createDate', + 'createdAt', ], }); } diff --git a/server/src/modules/workspace/services/workspaceMember.service.ts b/server/src/modules/workspace/services/workspaceMember.service.ts index b8485f13..45c41dfe 100644 --- a/server/src/modules/workspace/services/workspaceMember.service.ts +++ b/server/src/modules/workspace/services/workspaceMember.service.ts @@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { MongoRepository } from 'typeorm'; import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { ObjectId } from 'mongodb'; -import { RECORD_SUB_STATUS } from 'src/enums'; @Injectable() export class WorkspaceMemberService { @@ -24,25 +23,44 @@ export class WorkspaceMemberService { async batchCreate({ workspaceId, members, + creator, + creatorId, }: { workspaceId: string; members: Array<{ userId: string; role: string }>; + creator: string; + creatorId: string; }) { if (members.length === 0) { return { insertedCount: 0, }; } + const now = new Date(); const dataToInsert = members.map((item) => { return { ...item, workspaceId, + createdAt: now, + updatedAt: now, + creator, + creatorId, }; }); return this.workspaceMemberRepository.insertMany(dataToInsert); } - async batchUpdate({ idList, role }: { idList: Array; role: string }) { + async batchUpdate({ + idList, + role, + operator, + operatorId, + }: { + idList: Array; + role: string; + operator: string; + operatorId: string; + }) { if (idList.length === 0) { return { modifiedCount: 0, @@ -57,6 +75,9 @@ export class WorkspaceMemberService { { $set: { role, + operator, + operatorId, + updatedAt: new Date(), }, }, ); @@ -94,11 +115,8 @@ export class WorkspaceMemberService { return this.workspaceMemberRepository.find({ where: { workspaceId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }, - select: ['_id', 'createDate', 'curStatus', 'role', 'userId'], + select: ['_id', 'createdAt', 'curStatus', 'role', 'userId'], }); } @@ -111,7 +129,7 @@ export class WorkspaceMemberService { }); } - async updateRole({ workspaceId, userId, role }) { + async updateRole({ workspaceId, userId, role, operator, operatorId }) { return this.workspaceMemberRepository.updateOne( { workspaceId, @@ -120,6 +138,9 @@ export class WorkspaceMemberService { { $set: { role, + operator, + operatorId, + updatedAt: new Date(), }, }, ); @@ -135,9 +156,6 @@ export class WorkspaceMemberService { async countByWorkspaceId({ workspaceId }) { return this.workspaceMemberRepository.count({ workspaceId, - 'subStatus.status': { - $ne: RECORD_SUB_STATUS.REMOVED, - }, }); } diff --git a/server/src/securityPlugin/interface.ts b/server/src/securityPlugin/interface.ts index b402f704..a5e3b55f 100644 --- a/server/src/securityPlugin/interface.ts +++ b/server/src/securityPlugin/interface.ts @@ -1,6 +1,6 @@ -export interface XiaojuSurveyPlugin { - beforeResponseDataCreate?(responseData); +export interface SecurityPlugin { + encryptResponseData?(responseData); afterResponseFind?(responseData); - desensitiveData?(data: Record); + maskData?(data: Record); genSurveyPath?(); } diff --git a/server/src/securityPlugin/pluginManager.provider.ts b/server/src/securityPlugin/pluginManager.provider.ts index 4f13047a..032517a8 100644 --- a/server/src/securityPlugin/pluginManager.provider.ts +++ b/server/src/securityPlugin/pluginManager.provider.ts @@ -1,9 +1,7 @@ -import xiaojuSurveyPluginManager, { - XiaojuSurveyPluginManager, -} from './pluginManager'; +import securityPluginManager, { PluginManager } from './pluginManager'; import { Provider } from '@nestjs/common'; export const PluginManagerProvider: Provider = { - provide: XiaojuSurveyPluginManager, - useValue: xiaojuSurveyPluginManager, + provide: PluginManager, + useValue: securityPluginManager, }; diff --git a/server/src/securityPlugin/pluginManager.ts b/server/src/securityPlugin/pluginManager.ts index 67d5e2fe..dbd7c0c5 100644 --- a/server/src/securityPlugin/pluginManager.ts +++ b/server/src/securityPlugin/pluginManager.ts @@ -1,15 +1,15 @@ -import { XiaojuSurveyPlugin } from './interface'; +import { SecurityPlugin } from './interface'; type AllowHooks = - | 'beforeResponseDataCreate' - | 'afterResponseDataReaded' - | 'desensitiveData' + | 'encryptResponseData' + | 'decryptResponseData' + | 'maskData' | 'genSurveyPath'; -export class XiaojuSurveyPluginManager { - private plugins: Array = []; +export class PluginManager { + private plugins: Array = []; // 注册插件 - registerPlugin(...plugins: Array) { + registerPlugin(...plugins: Array) { this.plugins.push(...plugins); } @@ -23,4 +23,4 @@ export class XiaojuSurveyPluginManager { } } -export default new XiaojuSurveyPluginManager(); +export default new PluginManager(); diff --git a/server/src/securityPlugin/responseSecurityPlugin/index.ts b/server/src/securityPlugin/responseSecurityPlugin/index.ts index 65cf1a69..07e6a20a 100644 --- a/server/src/securityPlugin/responseSecurityPlugin/index.ts +++ b/server/src/securityPlugin/responseSecurityPlugin/index.ts @@ -1,15 +1,10 @@ -import { XiaojuSurveyPlugin } from '../interface'; +import { SecurityPlugin } from '../interface'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; -import { - decryptData, - encryptData, - isDataSensitive, - desensitiveData, -} from './utils'; +import { decryptData, encryptData, isDataSensitive, maskData } from './utils'; -export class ResponseSecurityPlugin implements XiaojuSurveyPlugin { +export class ResponseSecurityPlugin implements SecurityPlugin { constructor(private readonly secretKey: string) {} - beforeResponseDataCreate(responseData: SurveyResponse) { + encryptResponseData(responseData: SurveyResponse) { const secretKeys = []; if (responseData.data) { for (const key in responseData.data) { @@ -39,7 +34,7 @@ export class ResponseSecurityPlugin implements XiaojuSurveyPlugin { responseData.secretKeys = secretKeys; } - afterResponseDataReaded(responseData: SurveyResponse) { + decryptResponseData(responseData: SurveyResponse) { const secretKeys = responseData.secretKeys; if (Array.isArray(secretKeys) && secretKeys.length > 0) { for (const key of secretKeys) { @@ -57,10 +52,10 @@ export class ResponseSecurityPlugin implements XiaojuSurveyPlugin { responseData.secretKeys = []; } - desensitiveData(data: Record) { + maskData(data: Record) { Object.keys(data).forEach((key) => { if (isDataSensitive(data[key])) { - data[key] = desensitiveData(data[key]); + data[key] = maskData(data[key]); } }); } diff --git a/server/src/securityPlugin/responseSecurityPlugin/utils.ts b/server/src/securityPlugin/responseSecurityPlugin/utils.ts index d2960e0d..dca43bdc 100644 --- a/server/src/securityPlugin/responseSecurityPlugin/utils.ts +++ b/server/src/securityPlugin/responseSecurityPlugin/utils.ts @@ -50,7 +50,7 @@ export const decryptData = (data, { secretKey }) => { return CryptoJS.AES.decrypt(data, secretKey).toString(CryptoJS.enc.Utf8); }; -export const desensitiveData = (data: string): string => { +export const maskData = (data: string): string => { if (!isString(data)) { return '*'; } diff --git a/server/src/securityPlugin/surveyUtilPlugin/index.ts b/server/src/securityPlugin/surveyUtilPlugin/index.ts index 9fb93354..a4b30948 100644 --- a/server/src/securityPlugin/surveyUtilPlugin/index.ts +++ b/server/src/securityPlugin/surveyUtilPlugin/index.ts @@ -1,10 +1,10 @@ -import { XiaojuSurveyPlugin } from '../interface'; +import { SecurityPlugin } from '../interface'; import { customAlphabet } from 'nanoid'; const surveyPathAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; -export class SurveyUtilPlugin implements XiaojuSurveyPlugin { +export class SurveyUtilPlugin implements SecurityPlugin { genSurveyPath() { const id = customAlphabet(surveyPathAlphabet, 8); return id(); diff --git a/server/src/utils/surveyUtil.ts b/server/src/utils/surveyUtil.ts index d8987a04..3056555f 100644 --- a/server/src/utils/surveyUtil.ts +++ b/server/src/utils/surveyUtil.ts @@ -62,7 +62,7 @@ export function getFilter(filterList: Array) { } export function getOrder(order: Array) { - const allowOrderFields = ['createDate', 'updateDate', 'curStatus.date']; + const allowOrderFields = ['createdAt', 'updatedAt', 'curStatus.date']; const orderList = order.filter((orderItem) => allowOrderFields.includes(orderItem.field), diff --git a/web/.gitignore b/web/.gitignore index 94556503..4be13a92 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -8,6 +8,8 @@ node_modules .env.production.local .env.local +components.d.ts + # Log files npm-debug.log* yarn-debug.log* diff --git a/web/package.json b/web/package.json index 24781d81..f9060096 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { - "name": "web", - "version": "0.1.0", - "private": true, + "name": "xiaoju-survey-web", + "version": "1.3.0", + "description": "XIAOJUSURVEY的web端,包含B端和C端应用", "type": "module", "scripts": { "serve": "npm run dev", @@ -23,13 +23,14 @@ "clipboard": "^2.0.11", "crypto-js": "^4.2.0", "echarts": "^5.5.0", - "element-plus": "^2.8.1", + "element-plus": "^2.8.3", "lodash-es": "^4.17.21", "moment": "^2.29.4", "nanoid": "^5.0.7", "node-forge": "^1.3.1", "pinia": "^2.1.7", "qrcode": "^1.5.3", + "uuid": "^10.0.0", "vue": "^3.4.15", "vue-router": "^4.2.5", "vuedraggable": "^4.1.0", @@ -40,9 +41,11 @@ "@iconify-json/ep": "^1.1.15", "@rushstack/eslint-patch": "^1.10.2", "@tsconfig/node20": "^20.1.2", + "@types/fs-extra": "^11.0.4", "@types/lodash-es": "^4.17.12", "@types/node": "^20.11.19", "@types/qrcode": "^1.5.5", + "@types/uuid": "^10.0.0", "@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue-jsx": "^3.1.0", "@vue/eslint-config-prettier": "^8.0.0", @@ -50,6 +53,7 @@ "@vue/tsconfig": "^0.5.1", "eslint": "^8.49.0", "eslint-plugin-vue": "^9.17.0", + "fs-extra": "^11.2.0", "husky": "^9.0.11", "npm-run-all2": "^6.1.1", "prettier": "^3.0.3", diff --git a/web/report.ts b/web/report.ts new file mode 100644 index 00000000..edb43840 --- /dev/null +++ b/web/report.ts @@ -0,0 +1,47 @@ +import fs from 'fs-extra' + +const fsa = fs.promises + +process.env.XIAOJU_SURVEY_REPORT = 'true' + +const readData = async (pkg: string) => { + const id = new Date().getTime().toString() + try { + if (!fs.existsSync(pkg)) { + return { + type: 'web', + name: '', + version: '', + description: '', + id, + msg: '文件不存在' + } + } + const data = await fsa.readFile(pkg, 'utf8').catch((e) => e) + const { name, version, description } = JSON.parse(data) + return { type: 'web', name, version, description, id } + } catch (error) { + return error + } +} + +const report = async () => { + if (!process.env.XIAOJU_SURVEY_REPORT) { + return + } + + const res = await readData('./package.json') + + // 上报 + fetch && + fetch('https://xiaojusurveysrc.didi.cn/reportSourceData', { + method: 'POST', + headers: { + Accept: 'application/json, */*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(res) + }).catch(() => {}) +} + +report() diff --git a/web/src/common/xss.js b/web/src/common/xss.js index 72608018..dea4b2e8 100644 --- a/web/src/common/xss.js +++ b/web/src/common/xss.js @@ -9,7 +9,7 @@ const myxss = new xss.FilterXSS({ }, onIgnoreTag(tag, html) { // 过滤为空,否则不过滤为空 - var re1 = new RegExp('<.+?>', 'g') + const re1 = new RegExp('<.+?>', 'g') if (re1.test(html)) { return '' } else { diff --git a/web/src/management/App.vue b/web/src/management/App.vue index cc283ad9..4f2044f7 100644 --- a/web/src/management/App.vue +++ b/web/src/management/App.vue @@ -2,10 +2,69 @@ - diff --git a/web/src/management/config/listConfig.js b/web/src/management/config/listConfig.js index c095c60a..890fb2c8 100644 --- a/web/src/management/config/listConfig.js +++ b/web/src/management/config/listConfig.js @@ -27,9 +27,9 @@ export const spaceListConfig = { key: 'owner', width: 150 }, - createDate: { + createdAt: { title: '创建时间', - key: 'createDate', + key: 'createdAt', minWidth: 200 } } @@ -64,14 +64,14 @@ export const fieldConfig = { key: 'owner', width: 140 }, - updateDate: { + updatedAt: { title: '更新时间', - key: 'subStatus.date', + key: 'updatedAt', minWidth: 200 }, - createDate: { + createdAt: { title: '创建时间', - key: 'createDate', + key: 'createdAt', minWidth: 200 } } @@ -88,15 +88,20 @@ export const noSpaceDataConfig = { img: '/imgs/icons/list-empty.webp' } export const noSpaceSearchDataConfig = { - title: '没有满足该查询条件的团队空间哦', + title: '没有满足该查询条件的团队空间', desc: '可以更换条件查询试试', img: '/imgs/icons/list-empty.webp' } export const noSearchDataConfig = { - title: '没有满足该查询条件的问卷哦', + title: '没有满足该查询条件的问卷', desc: '可以更换条件查询试试', img: '/imgs/icons/list-empty.webp' } +export const noDownloadTaskConfig = { + title: '没有下载任务', + desc: '可以在数据分析进行下载', + img: '/imgs/icons/list-empty.webp' +} export const curStatus = { new: { @@ -106,15 +111,15 @@ export const curStatus = { published: { value: 'published', label: '已发布' - } -} - -// 子状态 -export const subStatus = { + }, editing: { label: '修改中', value: 'editing' }, +} + +// 子状态 +export const subStatus = { pausing: { label: '暂停中', value: 'pausing' @@ -171,7 +176,7 @@ export const curStatusSelect = { }, curStatus.new, curStatus.published, - subStatus.editing, + curStatus.editing, subStatus.pausing ], default: '' @@ -183,7 +188,7 @@ export const selectOptionsDict = Object.freeze({ }) export const buttonOptionsDict = Object.freeze({ - 'subStatus.date': { + 'updatedAt': { label: '更新时间', icons: [ { @@ -204,7 +209,7 @@ export const buttonOptionsDict = Object.freeze({ } ] }, - createDate: { + createdAt: { label: '创建时间', icons: [ { diff --git a/web/src/management/config/skinPresets.js b/web/src/management/config/skinPresets.js index a3f47ca0..8ef78ae6 100644 --- a/web/src/management/config/skinPresets.js +++ b/web/src/management/config/skinPresets.js @@ -1,6 +1,6 @@ export default { 'default-1': { - 'skinConf.backgroundConf.color': '#90b4fa', - 'skinConf.themeConf.color': '#FAA600' + 'skinConf.backgroundConf.color': '#b8dbff', + 'skinConf.themeConf.color': '#faa600' } } diff --git a/web/src/management/hooks/useResizeObserver.js b/web/src/management/hooks/useResizeObserver.js index cbf400ea..61e584a2 100644 --- a/web/src/management/hooks/useResizeObserver.js +++ b/web/src/management/hooks/useResizeObserver.js @@ -1,5 +1,5 @@ // 引入防抖函数 -import { debounce as _debounce } from 'lodash-es' +import { debounce } from 'lodash-es' /** * @description: 监听元素尺寸变化 * @param {*} el 元素dom @@ -8,7 +8,7 @@ import { debounce as _debounce } from 'lodash-es' * @return {*} */ export default (el, cb, wait = 200) => { - const resizeObserver = new ResizeObserver(_debounce(cb, wait)) + const resizeObserver = new ResizeObserver(debounce(cb, wait)) resizeObserver.observe(el) diff --git a/web/src/management/main.js b/web/src/management/main.js index aa44a4fe..1a0618b0 100644 --- a/web/src/management/main.js +++ b/web/src/management/main.js @@ -6,6 +6,10 @@ import safeHtml from './directive/safeHtml' import App from './App.vue' import router from './router' +import moment from 'moment' +import 'moment/locale/zh-cn' +moment.locale('zh-cn') + const pinia = createPinia() const app = createApp(App) diff --git a/web/src/management/pages/analysis/components/DataTable.vue b/web/src/management/pages/analysis/components/DataTable.vue index e6835d64..437315a9 100644 --- a/web/src/management/pages/analysis/components/DataTable.vue +++ b/web/src/management/pages/analysis/components/DataTable.vue @@ -60,6 +60,7 @@ diff --git a/web/src/management/pages/download/DownloadPage.vue b/web/src/management/pages/download/DownloadPage.vue new file mode 100644 index 00000000..189d604f --- /dev/null +++ b/web/src/management/pages/download/DownloadPage.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/management/pages/download/components/TaskList.vue b/web/src/management/pages/download/components/TaskList.vue new file mode 100644 index 00000000..4e199061 --- /dev/null +++ b/web/src/management/pages/download/components/TaskList.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/web/src/management/pages/edit/components/ModuleNavbar.vue b/web/src/management/pages/edit/components/ModuleNavbar.vue index 96cb7065..3a62f2f4 100644 --- a/web/src/management/pages/edit/components/ModuleNavbar.vue +++ b/web/src/management/pages/edit/components/ModuleNavbar.vue @@ -18,10 +18,15 @@ - + @@ -30,6 +35,8 @@ import { computed } from 'vue' import { useEditStore } from '@/management/stores/edit' import { storeToRefs } from 'pinia' +import { ElMessage } from 'element-plus' +import 'element-plus/theme-chalk/src/message.scss' import BackPanel from '../modules/generalModule/BackPanel.vue' import TitlePanel from '../modules/generalModule/TitlePanel.vue' @@ -39,6 +46,7 @@ import PreviewPanel from '../modules/contentModule/PreviewPanel.vue' import SavePanel from '../modules/contentModule/SavePanel.vue' import PublishPanel from '../modules/contentModule/PublishPanel.vue' import CooperationPanel from '../modules/contentModule/CooperationPanel.vue' +import { seizeSession } from '@/management/api/survey' const editStore = useEditStore() const { schema, changeSchema } = editStore @@ -68,15 +76,17 @@ const updateLogicConf = () => { } const showLogicConf = showLogicEngine.value.toJson() - - // 更新逻辑配置 - changeSchema({ key: 'logicConf', value: { showLogicConf } }) + if (JSON.stringify(schema.logicConf.showLogicConf) !== JSON.stringify(showLogicConf)) { + // 更新逻辑配置 + changeSchema({ key: 'logicConf', value: { showLogicConf } }) + } return res } - const jumpLogicConf = jumpLogicEngine.value.toJson() - changeSchema({ key: 'logicConf', value: { jumpLogicConf } }) + if (JSON.stringify(schema.logicConf.jumpLogicConf) !== JSON.stringify(jumpLogicConf)) { + changeSchema({ key: 'logicConf', value: { jumpLogicConf } }) + } return res } @@ -104,6 +114,16 @@ const updateWhiteConf = () => { } return res } + +// 重新获取sessionid +const seize = async (sessionId: string) => { + const seizeRes: Record = await seizeSession({ sessionId }) + if (seizeRes.code === 200) { + location.reload() + } else { + ElMessage.error('获取权限失败,请重试') + } +} diff --git a/web/src/materials/setters/widgets/UploadSingleFile.vue b/web/src/materials/setters/widgets/UploadSingleFile.vue new file mode 100644 index 00000000..bc261eb8 --- /dev/null +++ b/web/src/materials/setters/widgets/UploadSingleFile.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/web/src/render/App.vue b/web/src/render/App.vue index 28416b75..6a29394c 100644 --- a/web/src/render/App.vue +++ b/web/src/render/App.vue @@ -9,28 +9,26 @@ import { useSurveyStore } from './stores/survey' const { skinConf } = storeToRefs(useSurveyStore()) -const updateSkinConfig = (value: any) => { +watch(skinConf, (skinConfig) => { const root = document.documentElement - const { themeConf, backgroundConf, contentConf } = value + const { themeConf, backgroundConf, contentConf }: any = skinConfig if (themeConf?.color) { // 设置主题颜色 root.style.setProperty('--primary-color', themeConf?.color) } - if (backgroundConf?.color) { - // 设置背景颜色 - root.style.setProperty('--primary-background-color', backgroundConf?.color) - } + // 设置背景 + const { color, type, image } = backgroundConf || {} + root.style.setProperty( + '--primary-background', + type === 'image' ? `url(${image}) no-repeat center / cover` : color + ) if (contentConf?.opacity.toString()) { // 设置全局透明度 - root.style.setProperty('--opacity', `${parseInt(contentConf.opacity) / 100}`) + root.style.setProperty('--opacity', `${contentConf.opacity / 100}`) } -} - -watch(skinConf, (value) => { - updateSkinConfig(value) }) diff --git a/web/src/render/api/survey.js b/web/src/render/api/survey.js index 51b72180..dd275291 100644 --- a/web/src/render/api/survey.js +++ b/web/src/render/api/survey.js @@ -33,7 +33,7 @@ export const getEncryptInfo = () => { return axios.get('/clientEncrypt/getEncryptInfo') } -export const validate = ({ surveyPath, password, whitelist }) => { +export const validate = ({ surveyPath, password = '', whitelist = '' }) => { return axios.post(`/responseSchema/${surveyPath}/validate`, { password, whitelist diff --git a/web/src/render/components/AlertDialog.vue b/web/src/render/components/AlertDialog.vue index 7fa04e6e..2a569800 100644 --- a/web/src/render/components/AlertDialog.vue +++ b/web/src/render/components/AlertDialog.vue @@ -2,7 +2,7 @@
{{ title }}
-
{{ btnText }}
+
{{ btnText }}
@@ -34,18 +34,8 @@ const handleConfirm = () => { diff --git a/web/src/render/components/ConfirmDialog.vue b/web/src/render/components/ConfirmDialog.vue index 82c0e40c..049b6bcf 100644 --- a/web/src/render/components/ConfirmDialog.vue +++ b/web/src/render/components/ConfirmDialog.vue @@ -1,74 +1,117 @@ diff --git a/web/src/render/components/VerifyDialog/WhiteListDialog.vue b/web/src/render/components/VerifyDialog/WhiteListDialog.vue new file mode 100644 index 00000000..6ccea9dd --- /dev/null +++ b/web/src/render/components/VerifyDialog/WhiteListDialog.vue @@ -0,0 +1,86 @@ + + + diff --git a/web/src/render/components/VerifyDialog/index.vue b/web/src/render/components/VerifyDialog/index.vue new file mode 100644 index 00000000..9dc900fb --- /dev/null +++ b/web/src/render/components/VerifyDialog/index.vue @@ -0,0 +1,73 @@ + + + diff --git a/web/src/render/components/VerifyWhiteDialog.vue b/web/src/render/components/VerifyWhiteDialog.vue deleted file mode 100644 index 4e61768f..00000000 --- a/web/src/render/components/VerifyWhiteDialog.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - diff --git a/web/src/render/hooks/useShowInput.js b/web/src/render/hooks/useInputData.js similarity index 95% rename from web/src/render/hooks/useShowInput.js rename to web/src/render/hooks/useInputData.js index b4982c4f..8a1444f3 100644 --- a/web/src/render/hooks/useShowInput.js +++ b/web/src/render/hooks/useInputData.js @@ -1,7 +1,7 @@ import { useQuestionStore } from '../stores/question' import { useSurveyStore } from '../stores/survey' -export const useShowInput = (questionKey) => { +export const useInputData = (questionKey) => { const questionStore = useQuestionStore() const surveyStore = useSurveyStore() const formValues = surveyStore.formValues diff --git a/web/src/render/hooks/useShowOthers.js b/web/src/render/hooks/useOthersData.js similarity index 95% rename from web/src/render/hooks/useShowOthers.js rename to web/src/render/hooks/useOthersData.js index df5a8b9e..a60c2809 100644 --- a/web/src/render/hooks/useShowOthers.js +++ b/web/src/render/hooks/useOthersData.js @@ -1,7 +1,7 @@ import { useQuestionStore } from '../stores/question' import { useSurveyStore } from '../stores/survey' -export const useShowOthers = (questionKey) => { +export const useOthersData = (questionKey) => { const questionStore = useQuestionStore() const surveyStore = useSurveyStore() const formValues = surveyStore.formValues diff --git a/web/src/render/hooks/useProgress.js b/web/src/render/hooks/useProgress.js index b76981cb..4401ecec 100644 --- a/web/src/render/hooks/useProgress.js +++ b/web/src/render/hooks/useProgress.js @@ -38,7 +38,7 @@ export const useProgressBar = () => { const percent = computed(() => { const { fillCount, topicCount } = surveySchedule.value - return Math.floor((100 / topicCount) * fillCount) + '%' + return (Math.floor((100 / topicCount) * fillCount) || 0) + '%' }) return { surveySchedule, percent } diff --git a/web/src/render/hooks/useQuestionInfo.ts b/web/src/render/hooks/useQuestionInfo.ts new file mode 100644 index 00000000..baff1d7d --- /dev/null +++ b/web/src/render/hooks/useQuestionInfo.ts @@ -0,0 +1,20 @@ +import { useQuestionStore } from '@/render/stores/question' +import { cleanRichText } from '@/common/xss' +export const useQuestionInfo = (field: string) => { + const questionstore = useQuestionStore() + + const questionTitle = cleanRichText(questionstore.questionData[field]?.title) + const getOptionTitle = (value: any) => { + const options = questionstore.questionData[field]?.options || [] + if (value instanceof Array) { + return options + .filter((item: any) => value.includes(item.hash)) + .map((item: any) => cleanRichText(item.text)) + } else { + return options + .filter((item: any) => item.hash === value) + .map((item: any) => cleanRichText(item.text)) + } + } + return { questionTitle, getOptionTitle } +} diff --git a/web/src/render/index.html b/web/src/render/index.html index cd6aaa9b..6358a23d 100644 --- a/web/src/render/index.html +++ b/web/src/render/index.html @@ -9,9 +9,9 @@ @@ -156,13 +175,11 @@ const handleSubmit = () => { .wrapper { min-height: 100%; - background-color: var(--primary-background-color); display: flex; flex-direction: column; .content { flex: 1; - margin: 0 0.3rem; background: rgba(255, 255, 255, var(--opacity)); border-radius: 8px 8px 0 0; height: 100%; diff --git a/web/src/render/pages/SuccessPage.vue b/web/src/render/pages/SuccessPage.vue index a6fc73b9..8ea66acd 100644 --- a/web/src/render/pages/SuccessPage.vue +++ b/web/src/render/pages/SuccessPage.vue @@ -30,7 +30,9 @@ import communalLoader from '@materials/communals/communalLoader.js' const LogoIcon = communalLoader.loadComponent('LogoIcon') const surveyStore = useSurveyStore() -const logoConf = computed(() => surveyStore?.bottomConf || {}) +const logoConf = computed(() => { + return surveyStore?.bottomConf || {} +}) const successMsg = computed(() => { const msgContent = (surveyStore?.submitConf as any)?.msgContent || {} return msgContent?.msg_200 || '提交成功' diff --git a/web/src/render/plugins/dialog/index.js b/web/src/render/plugins/dialog/index.js deleted file mode 100644 index 433c334d..00000000 --- a/web/src/render/plugins/dialog/index.js +++ /dev/null @@ -1,56 +0,0 @@ -import ConfirmDialog from '../../components/ConfirmDialog.vue' -import AlertDialog from '../../components/AlertDialog.vue' - -import { isFunction as _isFunction } from 'lodash-es' - -export default { - install(Vue) { - Vue.prototype.$dialog = { - confirm(options) { - const MyComponent = Vue.extend(ConfirmDialog) - const instance = new MyComponent({ - propsData: options - }) - const closeConfirm = () => { - if (instance && instance.$el) { - instance.$el.remove() - } - } - instance.$on('cancel', () => { - if (options?.onCancel && _isFunction(options.onCancel)) { - options.onCancel(closeConfirm) - } else { - closeConfirm() - } - }) - instance.$on('confirm', () => { - if (options?.onConfirm && _isFunction(options.onConfirm)) { - options.onConfirm(closeConfirm) - } - }) - instance.$mount() - document.body.append(instance.$el) - }, - alert(options) { - const MyComponent = Vue.extend(AlertDialog) - const instance = new MyComponent({ - propsData: options - }) - const closeConfirm = () => { - if (instance && instance.$el) { - instance.$el.remove() - } - } - instance.$on('confirm', () => { - if (options?.onConfirm && _isFunction(options.onConfirm)) { - options.onConfirm(closeConfirm) - } else { - closeConfirm() - } - }) - instance.$mount() - document.body.append(instance.$el) - } - } - } -} diff --git a/web/src/render/stores/question.js b/web/src/render/stores/question.js index 3efa602b..8e8e9fbf 100644 --- a/web/src/render/stores/question.js +++ b/web/src/render/stores/question.js @@ -3,11 +3,101 @@ import { defineStore } from 'pinia' import { set } from 'lodash-es' import { useSurveyStore } from '@/render/stores/survey' import { queryVote } from '@/render/api/survey' +import { QUESTION_TYPE } from '@/common/typeEnum' -const VOTE_INFO_KEY = 'voteinfo' +import { getVoteData, setVoteData, clearVoteData } from '@/render/utils/storage' + +// 投票进度逻辑聚合 +const useVoteMap = (questionData) => { + const voteMap = ref({}) + //初始化投票题的数据 + const initVoteData = async () => { + const surveyStore = useSurveyStore() + const surveyPath = surveyStore.surveyPath + + const fieldList = [] + + for (const field in questionData.value) { + const { type } = questionData.value[field] + if (type.includes(QUESTION_TYPE.VOTE)) { + fieldList.push(field) + } + } + + if (fieldList.length <= 0) { + return + } + try { + clearVoteData() + const voteRes = await queryVote({ + surveyPath, + fieldList: fieldList.join(',') + }) + + if (voteRes.code === 200) { + setVoteData(voteRes.data) + setVoteMap(voteRes.data) + } + } catch (error) { + console.log(error) + } + } + const updateVoteMapByKey = (data) => { + const { questionKey, voteKey, voteValue } = data + // 兼容为空的情况 + if (!voteMap.value[questionKey]) { + voteMap.value[questionKey] = {} + } + voteMap.value[questionKey][voteKey] = voteValue + } + const setVoteMap = (data) => { + voteMap.value = data + } + const updateVoteData = (data) => { + const { key: questionKey, value: questionVal } = data + // 更新前获取接口缓存在localstorage中的数据 + const voteInfo = getVoteData() + const currentQuestion = questionData.value[questionKey] + const options = currentQuestion.options + const voteTotal = voteInfo?.[questionKey]?.total || 0 + let totalPayload = { + questionKey, + voteKey: 'total', + voteValue: voteTotal + } + options.forEach((option) => { + const optionHash = option.hash + const voteCount = voteInfo?.[questionKey]?.[optionHash] || 0 + // 如果选中值包含该选项,对应voteCount 和 voteTotal + 1 + if ( + Array.isArray(questionVal) ? questionVal.includes(optionHash) : questionVal === optionHash + ) { + const countPayload = { + questionKey, + voteKey: optionHash, + voteValue: voteCount + 1 + } + totalPayload.voteValue += 1 + updateVoteMapByKey(countPayload) + } else { + const countPayload = { + questionKey, + voteKey: optionHash, + voteValue: voteCount + } + updateVoteMapByKey(countPayload) + } + updateVoteMapByKey(totalPayload) + }) + } + return { + voteMap, + initVoteData, + updateVoteData + } +} export const useQuestionStore = defineStore('question', () => { - const voteMap = ref({}) const questionData = ref(null) const questionSeq = ref([]) // 题目的顺序,因为可能会有分页的情况,所以是一个二维数组[[qid1, qid2], [qid3,qid4]] const pageIndex = ref(1) // 当前分页的索引 @@ -53,6 +143,9 @@ export const useQuestionStore = defineStore('question', () => { const isFinallyPage = computed(() => { const surveyStore = useSurveyStore() + if (surveyStore.pageConf.length === 0) { + return true + } return pageIndex.value === surveyStore.pageConf.length }) @@ -82,6 +175,7 @@ export const useQuestionStore = defineStore('question', () => { const setQuestionData = (data) => { questionData.value = data } + const { voteMap, setVoteMap, initVoteData, updateVoteData } = useVoteMap(questionData) const changeSelectMoreData = (data) => { const { key, value, field } = data @@ -92,96 +186,6 @@ export const useQuestionStore = defineStore('question', () => { questionSeq.value = data } - const setVoteMap = (data) => { - voteMap.value = data - } - - const updateVoteMapByKey = (data) => { - const { questionKey, voteKey, voteValue } = data - // 兼容为空的情况 - if (!voteMap.value[questionKey]) { - voteMap.value[questionKey] = {} - } - voteMap.value[questionKey][voteKey] = voteValue - } - - //初始化投票题的数据 - const initVoteData = async () => { - const surveyStore = useSurveyStore() - const surveyPath = surveyStore.surveyPath - - const fieldList = [] - - for (const field in questionData.value) { - const { type } = questionData.value[field] - if (/vote/.test(type)) { - fieldList.push(field) - } - } - - if (fieldList.length <= 0) { - return - } - try { - localStorage.removeItem(VOTE_INFO_KEY) - const voteRes = await queryVote({ - surveyPath, - fieldList: fieldList.join(',') - }) - - if (voteRes.code === 200) { - localStorage.setItem( - VOTE_INFO_KEY, - JSON.stringify({ - ...voteRes.data - }) - ) - setVoteMap(voteRes.data) - } - } catch (error) { - console.log(error) - } - } - - const updateVoteData = (data) => { - const { key: questionKey, value: questionVal } = data - // 更新前获取接口缓存在localStorage中的数据 - const localData = localStorage.getItem(VOTE_INFO_KEY) - const voteinfo = JSON.parse(localData) - const currentQuestion = questionData.value[questionKey] - const options = currentQuestion.options - const voteTotal = voteinfo?.[questionKey]?.total || 0 - let totalPayload = { - questionKey, - voteKey: 'total', - voteValue: voteTotal - } - options.forEach((option) => { - const optionhash = option.hash - const voteCount = voteinfo?.[questionKey]?.[optionhash] || 0 - // 如果选中值包含该选项,对应voteCount 和 voteTotal + 1 - if ( - Array.isArray(questionVal) ? questionVal.includes(optionhash) : questionVal === optionhash - ) { - const countPayload = { - questionKey, - voteKey: optionhash, - voteValue: voteCount + 1 - } - totalPayload.voteValue += 1 - updateVoteMapByKey(countPayload) - } else { - const countPayload = { - questionKey, - voteKey: optionhash, - voteValue: voteCount - } - updateVoteMapByKey(countPayload) - } - updateVoteMapByKey(totalPayload) - }) - } - const setChangeField = (field) => { changeField.value = field } @@ -199,7 +203,6 @@ export const useQuestionStore = defineStore('question', () => { needHideFields.value = needHideFields.value.filter((field) => !fields.includes(field)) } return { - voteMap, questionData, questionSeq, renderData, @@ -209,8 +212,8 @@ export const useQuestionStore = defineStore('question', () => { setQuestionData, changeSelectMoreData, setQuestionSeq, + voteMap, setVoteMap, - updateVoteMapByKey, initVoteData, updateVoteData, changeField, diff --git a/web/src/render/stores/survey.js b/web/src/render/stores/survey.js index 838ec827..822a18b3 100644 --- a/web/src/render/stores/survey.js +++ b/web/src/render/stores/survey.js @@ -2,22 +2,16 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { defineStore } from 'pinia' import { pick } from 'lodash-es' +import moment from 'moment' import { isMobile as isInMobile } from '@/render/utils/index' + import { getEncryptInfo as getEncryptInfoApi } from '@/render/api/survey' import { useQuestionStore } from '@/render/stores/question' import { useErrorInfo } from '@/render/stores/errorInfo' -import moment from 'moment' -// 引入中文 -import 'moment/locale/zh-cn' -// 设置中文 -moment.locale('zh-cn') - import adapter from '../adapter' import { RuleMatch } from '@/common/logicEngine/RulesMatch' -// import { jumpLogicRule } from '@/common/logicEngine/jumpLogicRule' - /** * CODE_MAP不从management引入,在dev阶段,会导致B端 router被加载,进而导致C端路由被添加 baseUrl: /management */ @@ -26,6 +20,7 @@ const CODE_MAP = { ERROR: 500, NO_AUTH: 403 } + export const useSurveyStore = defineStore('survey', () => { const surveyPath = ref('') const isMobile = ref(isInMobile()) @@ -58,6 +53,10 @@ export const useSurveyStore = defineStore('survey', () => { enterTime.value = Date.now() } + const setFormValues = (data) => { + formValues.value = data + } + const getEncryptInfo = async () => { try { const res = await getEncryptInfoApi() @@ -70,17 +69,17 @@ export const useSurveyStore = defineStore('survey', () => { } const canFillQuestionnaire = (baseConf, submitConf) => { - const { begTime, endTime, answerBegTime, answerEndTime } = baseConf + const { beginTime, endTime, answerBegTime, answerEndTime } = baseConf const { msgContent } = submitConf const now = Date.now() let isSuccess = true - if (now < new Date(begTime).getTime()) { + if (now < new Date(beginTime).getTime()) { isSuccess = false setErrorInfo({ errorType: 'overTime', errorMsg: `

问卷未到开始填写时间,暂时无法进行填写

-

开始时间为: ${begTime}

` +

开始时间为: ${beginTime}

` }) } else if (now > new Date(endTime).getTime()) { isSuccess = false @@ -109,13 +108,8 @@ export const useSurveyStore = defineStore('survey', () => { return isSuccess } - const initSurvey = (option) => { - setEnterTime() - - if (!canFillQuestionnaire(option.baseConf, option.submitConf)) { - return - } - + // 加载空白页面 + function clearFormData(option) { // 根据初始的schema生成questionData, questionSeq, rules, formValues, 这四个字段 const { questionData, @@ -150,6 +144,16 @@ export const useSurveyStore = defineStore('survey', () => { pageConf.value = option.pageConf // 获取已投票数据 questionStore.initVoteData() + + } + + const initSurvey = (option) => { + setEnterTime() + if (!canFillQuestionnaire(option.baseConf, option.submitConf)) { + return + } + // 加载空白问卷 + clearFormData(option) } // 用户输入或者选择后,更新表单数据 @@ -161,13 +165,14 @@ export const useSurveyStore = defineStore('survey', () => { questionStore.setChangeField(key) } + // 初始化逻辑引擎 const showLogicEngine = ref() const initShowLogicEngine = (showLogicConf) => { - showLogicEngine.value = new RuleMatch().fromJson(showLogicConf) + showLogicEngine.value = new RuleMatch().fromJson(showLogicConf || []) } const jumpLogicEngine = ref() const initJumpLogicEngine = (jumpLogicConf) => { - jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf) + jumpLogicEngine.value = new RuleMatch().fromJson(jumpLogicConf || []) } return { @@ -188,6 +193,7 @@ export const useSurveyStore = defineStore('survey', () => { initSurvey, changeData, setWhiteData, + setFormValues, setSurveyPath, setEnterTime, getEncryptInfo, diff --git a/web/src/render/styles/dialog.scss b/web/src/render/styles/dialog.scss index 55845cc4..4def5a0c 100644 --- a/web/src/render/styles/dialog.scss +++ b/web/src/render/styles/dialog.scss @@ -8,19 +8,70 @@ flex-direction: column; align-items: center; justify-content: center; - background-color: rgba(0, 0, 0, 0.3); + background-color: #292a36f2; } .box { - width: 6rem; + width: 6.3rem; background: #fff; - padding: 0.56rem 0.44rem 0.32rem; + padding: 0.56rem 0.44rem 0.4rem; +} + +.head-wrapper { + margin-bottom: 0.5rem; } .title { - font-size: 0.3rem; + font-size: 0.33rem; color: #4a4c5b; letter-spacing: 0; text-align: center; font-weight: 600; } + +.form-item { + margin-top: 0.2rem; +} + +.input-wrapper { + padding: 1px 11px; + background-color: #fff; + border-radius: 0.1rem; + box-shadow: 0 0 0 1px #dcdfe6 inset; +} +.input-wrapper:hover { + box-shadow: 0 0 0 1px var(--primary-color) inset; +} + +.input-inner { + width: 100%; + color: #606266; + font-size: 0.27rem; + height: 0.6rem; + line-height: 0.6rem; + padding: 0; + outline: none; + border: none; + background: none; + box-sizing: border-box; +} + +.btn { + font-size: 0.26rem; + border-radius: 0.04rem; + text-align: center; + padding: 0.1rem 0; + line-height: 0.5rem; + cursor: pointer; + + &.btn-shallow { + background: #fff; + color: #92949d; + border: 1px solid #e3e4e8; + } + + &.btn-primary { + background-color: var(--primary-color); + color: #fff; + } +} diff --git a/web/src/render/utils/eventbus.js b/web/src/render/utils/eventbus.js index 455d3332..8f9161f4 100644 --- a/web/src/render/utils/eventbus.js +++ b/web/src/render/utils/eventbus.js @@ -16,7 +16,7 @@ export default class EventBus { off(eventName, fn) { if (this.events[eventName]) { - for (var i = 0; i < this.events[eventName].length; i++) { + for (let i = 0; i < this.events[eventName].length; i++) { if (this.events[eventName][i] === fn) { this.events[eventName].splice(i, 1) break diff --git a/web/src/render/utils/storage.ts b/web/src/render/utils/storage.ts new file mode 100644 index 00000000..1d71fb62 --- /dev/null +++ b/web/src/render/utils/storage.ts @@ -0,0 +1,44 @@ +// 用于记录“问卷断点续答”的数据 +export const getSurveyData = (id: string): any => { + try { + return JSON.parse(localStorage.getItem(`${id}_questionData`) as string) || null + } catch (e) { + console.log(e) + } + + return null +} +export const setSurveyData = (id: string, formData: any = {}) => { + localStorage.setItem(`${id}_questionData`, JSON.stringify(formData)) +} +export const clearSurveyData = (id: string) => localStorage.removeItem(`${id}_questionData`) + +// 问卷是否提交过,用于“自动填充上次填写内容” +export const getSurveySubmit = (id: string): number => { + try { + return Number(JSON.parse(localStorage.getItem(`${id}_submit`) as string)) || 0 + } catch (e) { + console.log(e) + } + + return 0 +} +export const setSurveySubmit = (id: string, value: number) => { + localStorage.setItem(`${id}_submit`, JSON.stringify(value)) +} +export const clearSurveySubmit = (id: string) => localStorage.removeItem(`${id}_submit`) + +// 投票记录 +export const getVoteData = (): any => { + try { + return JSON.parse(localStorage.getItem('voteData') as string) || null + } catch (e) { + console.log(e) + } + + return null +} +export const setVoteData = (params: any) => { + localStorage.setItem('voteData', JSON.stringify(params)) +} +export const clearVoteData = () => localStorage.removeItem('voteData') diff --git a/web/vite.config.ts b/web/vite.config.ts index 10241d4c..ee989105 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,6 +12,8 @@ import IconsResolver from 'unplugin-icons/resolver' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import './report' + const isProd = process.env.NODE_ENV === 'production' const pages = createPages([ @@ -123,6 +125,10 @@ export default defineConfig({ target: 'http://127.0.0.1:3000', changeOrigin: true }, + '/exportfile': { + target: 'http://127.0.0.1:3000', + changeOrigin: true + }, // 静态文件的默认存储文件夹 '/userUpload': { target: 'http://127.0.0.1:3000',