diff --git a/.gitignore b/.gitignore index e0d3138e..e64c75db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules dist package-lock.json +yarn.lock # local env files .env.local @@ -25,7 +26,10 @@ pnpm-debug.log* *.sw? .history + components.d.ts # 默认的上传文件夹 userUpload +exportfile +yarn.lock \ No newline at end of file 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..b2286da4 100644 --- a/server/.env +++ b/server/.env @@ -1,9 +1,15 @@ XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey -XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017 -XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin +XIAOJU_SURVEY_MONGO_URL= # mongodb://127.0.0.1:27017 # 建议设置强密码 +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_RESPONSE_AES_ENCRYPT_SECRET_KEY= # dataAesEncryptSecretKey XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret 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..fc06b434 100644 --- a/server/package.json +++ b/server/package.json @@ -27,10 +27,11 @@ "@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,11 +42,14 @@ "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", - "typeorm": "^0.3.19" + "typeorm": "^0.3.19", + "xss": "^1.0.15" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -70,6 +74,7 @@ "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", "ts-jest": "^29.1.0", diff --git a/server/scripts/run-local.ts b/server/scripts/run-local.ts index 963d3bea..9e45ed64 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/src/app.module.ts b/server/src/app.module.ts index 3faa2987..bbc87911 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -40,7 +40,9 @@ import { LoggerProvider } from './logger/logger.provider'; import { PluginManagerProvider } from './securityPlugin/pluginManager.provider'; import { LogRequestMiddleware } from './middlewares/logRequest.middleware'; import { XiaojuSurveyPluginManager } from './securityPlugin/pluginManager'; -import { Logger } from './logger'; +import { XiaojuSurveyLogger } from './logger'; +import { DownloadTask } from './models/downloadTask.entity'; +import { Session } from './models/session.entity'; @Module({ imports: [ @@ -81,6 +83,8 @@ import { Logger } from './logger'; Workspace, WorkspaceMember, Collaborator, + DownloadTask, + Session, ], }; }, @@ -128,7 +132,7 @@ export class AppModule { ), new SurveyUtilPlugin(), ); - Logger.init({ + XiaojuSurveyLogger.init({ filename: this.configService.get('XIAOJU_SURVEY_LOGGER_FILENAME'), }); } diff --git a/server/src/config/index.ts b/server/src/config/index.ts new file mode 100644 index 00000000..2068c4de --- /dev/null +++ b/server/src/config/index.ts @@ -0,0 +1,21 @@ +const mongo = { + url: process.env.XIAOJU_SURVEY_MONGO_URL || 'mongodb://localhost:27017', + dbName: process.env.XIAOJU_SURVER_MONGO_DBNAME || 'xiaojuSurvey', +}; + +const session = { + expireTime: + parseInt(process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN) || 8 * 3600 * 1000, +}; + +const encrypt = { + type: process.env.XIAOJU_SURVEY_ENCRYPT_TYPE || 'aes', + aesCodelength: parseInt(process.env.XIAOJU_SURVEY_ENCRYPT_TYPE_LEN) || 10, //aes密钥长度 +}; + +const jwt = { + secret: process.env.XIAOJU_SURVEY_JWT_SECRET || 'xiaojuSurveyJwtSecret', + expiresIn: process.env.XIAOJU_SURVEY_JWT_EXPIRES_IN || '8h', +}; + +export { mongo, session, encrypt, jwt }; diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index 6e139ad9..8ead165e 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 acaa0e33..897a4b90 100644 --- a/server/src/enums/index.ts +++ b/server/src/enums/index.ts @@ -6,6 +6,9 @@ export enum RECORD_STATUS { PUBLISHED = 'published', // 发布 REMOVED = 'removed', // 删除 FORCE_REMOVED = 'forceRemoved', // 从回收站删除 + COMOPUTETING = 'computing', // 计算中 + FINISHED = 'finished', // 已完成 + ERROR = 'error', // 错误 } // 历史类型 diff --git a/server/src/guards/session.guard.ts b/server/src/guards/session.guard.ts new file mode 100644 index 00000000..041f97d8 --- /dev/null +++ b/server/src/guards/session.guard.ts @@ -0,0 +1,94 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { get } from 'lodash'; +import { NoPermissionException } from 'src/exceptions/noPermissionException'; +import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; +import { SessionService } from 'src/modules/survey/services/session.service'; +import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; +import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; +import { CollaboratorService } from 'src/modules/survey/services/collaborator.service'; + +@Injectable() +export class SessionGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly sessionService: SessionService, + private readonly surveyMetaService: SurveyMetaService, + private readonly workspaceMemberService: WorkspaceMemberService, + private readonly collaboratorService: CollaboratorService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + const sessionIdKey = this.reflector.get( + 'sessionId', + context.getHandler(), + ); + + const sessionId = get(request, sessionIdKey); + + if (!sessionId) { + throw new NoPermissionException('没有权限'); + } + + const saveSession = await this.sessionService.findOne(sessionId); + + request.saveSession = saveSession; + + const surveyId = saveSession.surveyId; + + const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); + + if (!surveyMeta) { + throw new SurveyNotFoundException('问卷不存在'); + } + + request.surveyMeta = surveyMeta; + + // 兼容老的问卷没有ownerId + if ( + surveyMeta.ownerId === user._id.toString() || + surveyMeta.owner === user.username + ) { + // 问卷的owner,可以访问和操作问卷 + return true; + } + + if (surveyMeta.workspaceId) { + const memberInfo = await this.workspaceMemberService.findOne({ + workspaceId: surveyMeta.workspaceId, + userId: user._id.toString(), + }); + if (!memberInfo) { + throw new NoPermissionException('没有权限'); + } + return true; + } + + const permissions = this.reflector.get( + 'surveyPermission', + context.getHandler(), + ); + + if (!Array.isArray(permissions) || permissions.length === 0) { + throw new NoPermissionException('没有权限'); + } + + const info = await this.collaboratorService.getCollaborator({ + surveyId, + userId: user._id.toString(), + }); + + if (!info) { + throw new NoPermissionException('没有权限'); + } + request.collaborator = info; + if ( + permissions.some((permission) => info.permissions.includes(permission)) + ) { + return true; + } + throw new NoPermissionException('没有权限'); + } +} 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..4ba7d49c 100644 --- a/server/src/interfaces/survey.ts +++ b/server/src/interfaces/survey.ts @@ -60,6 +60,7 @@ export interface DataItem { rangeConfig?: any; starStyle?: string; innerType?: string; + quotaNoDisplay?: boolean; } export interface Option { @@ -69,6 +70,7 @@ export interface Option { othersKey?: string; placeholderDesc: string; hash: string; + quota?: number; } export interface DataConf { diff --git a/server/src/logger/index.ts b/server/src/logger/index.ts index f1892b71..a933fe93 100644 --- a/server/src/logger/index.ts +++ b/server/src/logger/index.ts @@ -1,15 +1,15 @@ import * as log4js from 'log4js'; import moment from 'moment'; -import { Request } from 'express'; +import { Injectable, Scope } from '@nestjs/common'; const log4jsLogger = log4js.getLogger(); -export class Logger { +@Injectable({ scope: Scope.REQUEST }) +export class XiaojuSurveyLogger { private static inited = false; - - constructor() {} + private traceId: string; static init(config: { filename: string }) { - if (this.inited) { + if (XiaojuSurveyLogger.inited) { return; } log4js.configure({ @@ -30,25 +30,28 @@ export class Logger { default: { appenders: ['app'], level: 'trace' }, }, }); + XiaojuSurveyLogger.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.traceId ? `traceid=${this.traceId}||` : ''; return log4jsLogger[level]( `[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`, ); } - info(message, options?: { dltag?: string; req?: Request }) { + setTraceId(traceId: string) { + this.traceId = traceId; + } + + 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/logger.provider.ts b/server/src/logger/logger.provider.ts index 2a298dd4..cf7c7bbe 100644 --- a/server/src/logger/logger.provider.ts +++ b/server/src/logger/logger.provider.ts @@ -1,8 +1,8 @@ import { Provider } from '@nestjs/common'; -import { Logger } from './index'; +import { XiaojuSurveyLogger } from './index'; export const LoggerProvider: Provider = { - provide: Logger, - useClass: Logger, + provide: XiaojuSurveyLogger, + useClass: XiaojuSurveyLogger, }; diff --git a/server/src/logger/util.ts b/server/src/logger/util.ts index ada62779..4ffc3c1b 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'); diff --git a/server/src/middlewares/logRequest.middleware.ts b/server/src/middlewares/logRequest.middleware.ts index ade62496..d906b479 100644 --- a/server/src/middlewares/logRequest.middleware.ts +++ b/server/src/middlewares/logRequest.middleware.ts @@ -1,26 +1,25 @@ // logger.middleware.ts import { Injectable, NestMiddleware } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; -import { Logger } from '../logger/index'; // 替换为你实际的logger路径 +import { XiaojuSurveyLogger } from '../logger/index'; // 替换为你实际的logger路径 import { genTraceId } from '../logger/util'; @Injectable() export class LogRequestMiddleware implements NestMiddleware { - constructor(private readonly logger: Logger) {} + constructor(private readonly logger: XiaojuSurveyLogger) {} use(req: Request, res: Response, next: NextFunction) { const { method, originalUrl, ip } = req; const userAgent = req.get('user-agent') || ''; const startTime = Date.now(); const traceId = genTraceId({ ip }); - req['traceId'] = traceId; + this.logger.setTraceId(traceId); const query = JSON.stringify(req.query); const body = JSON.stringify(req.body); this.logger.info( `method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`, { dltag: 'request_in', - req, }, ); @@ -30,7 +29,6 @@ export class LogRequestMiddleware implements NestMiddleware { `status=${res.statusCode.toString()}||duration=${duration}ms`, { dltag: 'request_out', - req, }, ); }); 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/downloadTask.entity.ts b/server/src/models/downloadTask.entity.ts new file mode 100644 index 00000000..90bb7f0b --- /dev/null +++ b/server/src/models/downloadTask.entity.ts @@ -0,0 +1,34 @@ +import { Entity, Column } from 'typeorm'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'downloadTask' }) +export class DownloadTask extends BaseEntity { + @Column() + surveyId: string; + + @Column() + surveyPath: string; + + // 文件路径 + @Column() + url: string; + + // 文件key + @Column() + fileKey: string; + + // 任务创建人 + @Column() + ownerId: string; + + // 文件名 + @Column() + filename: string; + + // 文件大小 + @Column() + fileSize: string; + + @Column() + params: string; +} diff --git a/server/src/models/session.entity.ts b/server/src/models/session.entity.ts new file mode 100644 index 00000000..bee82cc1 --- /dev/null +++ b/server/src/models/session.entity.ts @@ -0,0 +1,18 @@ +import { Entity, Column, Index, ObjectIdColumn } from 'typeorm'; +import { ObjectId } from 'mongodb'; +import { BaseEntity } from './base.entity'; + +@Entity({ name: 'session' }) +export class Session extends BaseEntity { + @Index({ + expireAfterSeconds: 3600, + }) + @ObjectIdColumn() + _id: ObjectId; + + @Column() + surveyId: string; + + @Column() + userId: string; +} 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/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/auth.service.ts b/server/src/modules/auth/services/auth.service.ts index 059119c4..1573ec56 100644 --- a/server/src/modules/auth/services/auth.service.ts +++ b/server/src/modules/auth/services/auth.service.ts @@ -35,4 +35,13 @@ export class AuthService { } return user; } + + async expiredCheck(token: string) { + try { + verify(token, this.configService.get('XIAOJU_SURVEY_JWT_SECRET')); + } catch (err) { + return true; + } + return false; + } } diff --git a/server/src/modules/file/services/file.service.ts b/server/src/modules/file/services/file.service.ts index fb89ae1b..6cace9fa 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, + keepOriginFilename, }: { configKey: string; file: Express.Multer.File; pathPrefix: string; + keepOriginFilename?: boolean; }) { const handler = this.getHandler(configKey); - const { key } = await handler.upload(file, { pathPrefix }); + const { key } = await handler.upload(file, { + pathPrefix, + keepOriginFilename, + }); 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..83a95c5b 100644 --- a/server/src/modules/file/services/uploadHandlers/local.handler.ts +++ b/server/src/modules/file/services/uploadHandlers/local.handler.ts @@ -12,9 +12,14 @@ export class LocalHandler implements FileUploadHandler { async upload( file: Express.Multer.File, - options?: { pathPrefix?: string }, + options?: { pathPrefix?: string; keepOriginFilename?: boolean }, ): Promise<{ key: string }> { - const filename = await generateUniqueFilename(file.originalname); + let filename; + if (options?.keepOriginFilename) { + filename = file.originalname; + } else { + filename = await generateUniqueFilename(file.originalname); + } const filePath = join( options?.pathPrefix ? options?.pathPrefix : '', filename, @@ -35,6 +40,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/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..2d9f458a 100644 --- a/server/src/modules/survey/__test/collaborator.controller.spec.ts +++ b/server/src/modules/survey/__test/collaborator.controller.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CollaboratorController } from '../controllers/collaborator.controller'; import { CollaboratorService } from '../services/collaborator.service'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { CreateCollaboratorDto } from '../dto/createCollaborator.dto'; import { Collaborator } from 'src/models/collaborator.entity'; @@ -25,7 +25,7 @@ jest.mock('src/guards/workspace.guard'); describe('CollaboratorController', () => { let controller: CollaboratorController; let collaboratorService: CollaboratorService; - let logger: Logger; + let logger: XiaojuSurveyLogger; let userService: UserService; let surveyMetaService: SurveyMetaService; let workspaceMemberServie: WorkspaceMemberService; @@ -50,7 +50,7 @@ describe('CollaboratorController', () => { }, }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { error: jest.fn(), info: jest.fn(), @@ -84,7 +84,7 @@ describe('CollaboratorController', () => { controller = module.get(CollaboratorController); collaboratorService = module.get(CollaboratorService); - logger = module.get(Logger); + logger = module.get(XiaojuSurveyLogger); userService = module.get(UserService); surveyMetaService = module.get(SurveyMetaService); workspaceMemberServie = module.get( @@ -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/collaborator.service.spec.ts b/server/src/modules/survey/__test/collaborator.service.spec.ts index 34a1157e..f276cf9a 100644 --- a/server/src/modules/survey/__test/collaborator.service.spec.ts +++ b/server/src/modules/survey/__test/collaborator.service.spec.ts @@ -3,13 +3,13 @@ import { CollaboratorService } from '../services/collaborator.service'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Collaborator } from 'src/models/collaborator.entity'; import { MongoRepository } from 'typeorm'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { InsertManyResult, ObjectId } from 'mongodb'; describe('CollaboratorService', () => { let service: CollaboratorService; let repository: MongoRepository; - let logger: Logger; + let logger: XiaojuSurveyLogger; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -20,7 +20,7 @@ describe('CollaboratorService', () => { useClass: MongoRepository, }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { info: jest.fn(), }, @@ -32,7 +32,7 @@ describe('CollaboratorService', () => { repository = module.get>( getRepositoryToken(Collaborator), ); - logger = module.get(Logger); + logger = module.get(XiaojuSurveyLogger); }); describe('create', () => { diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index 5b9b5d82..9d40ced0 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -9,7 +9,7 @@ import { ResponseSchemaService } from '../../surveyResponse/services/responseSch import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { UserService } from 'src/modules/auth/services/user.service'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; @@ -28,7 +28,7 @@ describe('DataStatisticController', () => { let dataStatisticService: DataStatisticService; let responseSchemaService: ResponseSchemaService; let pluginManager: XiaojuSurveyPluginManager; - let logger: Logger; + let logger: XiaojuSurveyLogger; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -56,7 +56,7 @@ describe('DataStatisticController', () => { })), }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { error: jest.fn(), }, @@ -73,7 +73,7 @@ describe('DataStatisticController', () => { pluginManager = module.get( XiaojuSurveyPluginManager, ); - logger = module.get(Logger); + logger = module.get(XiaojuSurveyLogger); pluginManager.registerPlugin( new ResponseSecurityPlugin('dataAesEncryptSecretKey'), @@ -123,7 +123,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, @@ -169,7 +169,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 +187,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); }); }); diff --git a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts index cffd5a76..54a975fd 100644 --- a/server/src/modules/survey/__test/surveyHistory.controller.spec.ts +++ b/server/src/modules/survey/__test/surveyHistory.controller.spec.ts @@ -7,7 +7,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service'; import { UserService } from 'src/modules/auth/services/user.service'; import { AuthService } from 'src/modules/auth/services/auth.service'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); @@ -49,7 +49,7 @@ describe('SurveyHistoryController', () => { useClass: jest.fn().mockImplementation(() => ({})), }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { info: jest.fn(), error: jest.fn(), @@ -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..07532feb 100644 --- a/server/src/modules/survey/__test/surveyHistory.service.spec.ts +++ b/server/src/modules/survey/__test/surveyHistory.service.spec.ts @@ -78,7 +78,13 @@ describe('SurveyHistoryService', () => { .spyOn(repository, 'save') .mockResolvedValueOnce({} as SurveyHistory); - await service.addHistory({ surveyId, schema, type, user }); + await service.addHistory({ + surveyId, + schema, + type, + user, + sessionId: '', + }); expect(spyCreate).toHaveBeenCalledWith({ pageId: surveyId, diff --git a/server/src/modules/survey/controllers/collaborator.controller.ts b/server/src/modules/survey/controllers/collaborator.controller.ts index 08c97068..0545aeb1 100644 --- a/server/src/modules/survey/controllers/collaborator.controller.ts +++ b/server/src/modules/survey/controllers/collaborator.controller.ts @@ -20,7 +20,7 @@ import { SURVEY_PERMISSION, SURVEY_PERMISSION_DESCRIPTION, } from 'src/enums/surveyPermission'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; import { CollaboratorService } from '../services/collaborator.service'; @@ -40,7 +40,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service'; export class CollaboratorController { constructor( private readonly collaboratorService: CollaboratorService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, private readonly userService: UserService, private readonly surveyMetaService: SurveyMetaService, private readonly workspaceMemberServie: WorkspaceMemberService, @@ -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,7 +184,7 @@ export class CollaboratorController { neIdList: collaboratorIdList, userIdList: newCollaboratorUserIdList, }); - this.logger.info('batchDelete:' + JSON.stringify(delRes), { req }); + this.logger.info('batchDelete:' + JSON.stringify(delRes)); if (Array.isArray(newCollaborator) && newCollaborator.length > 0) { const insertRes = await this.collaboratorService.batchCreate({ surveyId: value.surveyId, @@ -208,7 +208,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 +225,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 +262,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 +288,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 +315,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..9a396b6c 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'; @@ -17,7 +16,7 @@ import { Authentication } from 'src/guards/authentication.guard'; import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager'; import { SurveyGuard } from 'src/guards/survey.guard'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { AggregationStatisDto } from '../dto/aggregationStatis.dto'; @@ -32,7 +31,7 @@ export class DataStatisticController { private readonly responseSchemaService: ResponseSchemaService, private readonly dataStatisticService: DataStatisticService, private readonly pluginManager: XiaojuSurveyPluginManager, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, ) {} @Get('/dataTable') @@ -44,7 +43,6 @@ export class DataStatisticController { async data( @Query() queryInfo, - @Request() req, ) { const { value, error } = await Joi.object({ surveyId: Joi.string().required(), @@ -53,7 +51,7 @@ export class DataStatisticController { 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; 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..23ac42cb --- /dev/null +++ b/server/src/modules/survey/controllers/downloadTask.controller.ts @@ -0,0 +1,188 @@ +import { + Controller, + Get, + Query, + HttpCode, + UseGuards, + SetMetadata, + Request, + Post, + Body, + // Response, +} 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 { XiaojuSurveyLogger } 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: XiaojuSurveyLogger, + ) {} + + @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, isDesensitive } = value; + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const id = await this.downloadTaskService.createDownloadTask({ + surveyId, + responseSchema, + operatorId: req.user._id.toString(), + params: { isDesensitive }, + }); + 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({ + ownerId: 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.curStatus = data.curStatus; + 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.createDate = moment(Number(data.createDate)).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.ownerId !== 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.ownerId !== req.user._id.toString()) { + throw new NoPermissionException('没有权限'); + } + + const delRes = await this.downloadTaskService.deleteDownloadTask({ + taskId, + }); + + 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..33b87a46 --- /dev/null +++ b/server/src/modules/survey/controllers/session.controller.ts @@ -0,0 +1,89 @@ +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 { XiaojuSurveyLogger } 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: XiaojuSurveyLogger, + ) {} + + @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) + @SetMetadata('sessionId', 'body.sessionId') + @SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE]) + @UseGuards(Authentication) + async seize( + @Request() + req, + ) { + const saveSession = req.saveSession; + + await this.sessionService.updateSessionToEditing({ + sessionId: saveSession._id.toString(), + surveyId: saveSession.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 6976b5c6..f68cc456 100644 --- a/server/src/modules/survey/controllers/survey.controller.ts +++ b/server/src/modules/survey/controllers/survey.controller.ts @@ -17,6 +17,7 @@ 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 'src/modules/surveyResponse/services/counter.service'; import BannerData from '../template/banner/index.json'; import { CreateSurveyDto } from '../dto/createSurvey.dto'; @@ -25,13 +26,15 @@ import { Authentication } from 'src/guards/authentication.guard'; import { HISTORY_TYPE } from 'src/enums'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { SurveyGuard } from 'src/guards/survey.guard'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { WorkspaceGuard } from 'src/guards/workspace.guard'; import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace'; +import { SessionService } from '../services/session.service'; import { MemberType, WhitelistType } from 'src/interfaces/survey'; +import { UserService } from 'src/modules/auth/services/user.service'; @ApiTags('survey') @Controller('/api/survey') @@ -42,7 +45,10 @@ export class SurveyController { private readonly responseSchemaService: ResponseSchemaService, private readonly contentSecurityService: ContentSecurityService, private readonly surveyHistoryService: SurveyHistoryService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, + private readonly counterService: CounterService, + private readonly sessionService: SessionService, + private readonly userService: UserService, ) {} @Get('/getBannerData') @@ -71,9 +77,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 +133,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.createDate <= latestEditingOne.updateDate) { + // 在当前用户打开之后,被其他页面保存过了 + 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({ @@ -198,7 +230,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); } @@ -241,15 +273,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; @@ -282,7 +312,7 @@ 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; diff --git a/server/src/modules/survey/controllers/surveyHistory.controller.ts b/server/src/modules/survey/controllers/surveyHistory.controller.ts index 6144fa3d..53c999c1 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'; @@ -15,16 +14,15 @@ import { SurveyHistoryService } from '../services/surveyHistory.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 { XiaojuSurveyLogger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; - @ApiTags('survey') @Controller('/api/surveyHisotry') export class SurveyHistoryController { constructor( private readonly surveyHistoryService: SurveyHistoryService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, ) {} @Get('/getList') @@ -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 3b965222..f9839a6b 100644 --- a/server/src/modules/survey/controllers/surveyMeta.controller.ts +++ b/server/src/modules/survey/controllers/surveyMeta.controller.ts @@ -19,7 +19,7 @@ import { getFilter, getOrder } from 'src/utils/surveyUtil'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { Authentication } from 'src/guards/authentication.guard'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { SurveyGuard } from 'src/guards/survey.guard'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { WorkspaceGuard } from 'src/guards/workspace.guard'; @@ -33,7 +33,7 @@ import { CollaboratorService } from '../services/collaborator.service'; export class SurveyMetaController { constructor( private readonly surveyMetaService: SurveyMetaService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, private readonly collaboratorService: CollaboratorService, ) {} @@ -51,9 +51,7 @@ 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; @@ -81,7 +79,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 +89,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(); 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..c5f11e3a --- /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 }) + isDesensitive: boolean; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + isDesensitive: 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..59d70dd8 100644 --- a/server/src/modules/survey/services/collaborator.service.ts +++ b/server/src/modules/survey/services/collaborator.service.ts @@ -3,14 +3,14 @@ import { Collaborator } from 'src/models/collaborator.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { MongoRepository } from 'typeorm'; import { ObjectId } from 'mongodb'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; @Injectable() export class CollaboratorService { constructor( @InjectRepository(Collaborator) private readonly collaboratorRepository: MongoRepository, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, ) {} async create({ surveyId, userId, permissions }) { 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..7f276075 --- /dev/null +++ b/server/src/modules/survey/services/downloadTask.service.ts @@ -0,0 +1,280 @@ +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 { RECORD_STATUS } from 'src/enums'; +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 { XiaojuSurveyLogger } from 'src/logger'; +import moment from 'moment'; + +@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: XiaojuSurveyLogger, + ) {} + + async createDownloadTask({ + surveyId, + responseSchema, + operatorId, + params, + }: { + surveyId: string; + responseSchema: ResponseSchema; + operatorId: string; + params: any; + }) { + const filename = `${responseSchema.title}-${params.isDesensitive ? '脱敏' : '原'}回收数据-${moment().format('YYYYMMDDHHmmss')}.xlsx`; + const downloadTask = this.downloadTaskRepository.create({ + surveyId, + surveyPath: responseSchema.surveyPath, + fileSize: '计算中', + ownerId: operatorId, + params: { + ...params, + title: responseSchema.title, + }, + filename, + }); + await this.downloadTaskRepository.save(downloadTask); + return downloadTask._id.toString(); + } + + async getDownloadTaskList({ + ownerId, + pageIndex, + pageSize, + }: { + ownerId: string; + pageIndex: number; + pageSize: number; + }) { + const where = { + ownerId, + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }; + const [surveyDownloadList, total] = + await this.downloadTaskRepository.findAndCount({ + where, + take: pageSize, + skip: (pageIndex - 1) * pageSize, + order: { + createDate: -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 }: { taskId: string }) { + const curStatus = { + status: RECORD_STATUS.REMOVED, + date: Date.now(), + }; + return this.downloadTaskRepository.updateOne( + { + _id: new ObjectId(taskId), + 'curStatus.status': { + $ne: RECORD_STATUS.REMOVED, + }, + }, + { + $set: { + curStatus, + }, + $push: { + statusList: curStatus as never, + }, + }, + ); + } + + processDownloadTask({ taskId }) { + DownloadTaskService.taskList.push(taskId); + if (!DownloadTaskService.isExecuting) { + this.executeTask(); + DownloadTaskService.isExecuting = true; + } + } + + private async executeTask() { + try { + for (const taskId of DownloadTaskService.taskList) { + const taskInfo = await this.getDownloadTaskById({ taskId }); + if (!taskInfo || taskInfo.curStatus.status === RECORD_STATUS.REMOVED) { + // 不存在或者已删除的,不处理 + continue; + } + await this.handleDownloadTask({ taskInfo }); + } + } finally { + DownloadTaskService.isExecuting = false; + } + } + + private async handleDownloadTask({ taskInfo }) { + try { + // 更新任务状态为计算中 + const updateRes = await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + curStatus: { + status: RECORD_STATUS.COMOPUTETING, + date: Date.now(), + }, + }, + }, + ); + + this.logger.info(JSON.stringify(updateRes)); + + // 开始计算任务 + const surveyId = taskInfo.surveyId; + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId(surveyId); + const where = { + pageId: surveyId, + 'curStatus.status': { + $ne: 'removed', + }, + }; + 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, ''); + const $ = load(val); + const text = $.text(); + bodyData.push(text); + } + 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', + keepOriginFilename: true, + }); + + const curStatus = { + status: RECORD_STATUS.FINISHED, + date: Date.now(), + }; + + // 更新计算结果 + const updateFinishRes = await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + curStatus, + url, + fileKey: key, + fileSize: buffer.length, + }, + $push: { + statusList: curStatus as never, + }, + }, + ); + this.logger.info(JSON.stringify(updateFinishRes)); + } catch (error) { + const curStatus = { + status: RECORD_STATUS.ERROR, + date: Date.now(), + }; + await this.downloadTaskRepository.updateOne( + { + _id: taskInfo._id, + }, + { + $set: { + curStatus, + }, + $push: { + statusList: curStatus as never, + }, + }, + ); + 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..e70fd3da --- /dev/null +++ b/server/src/modules/survey/services/session.service.ts @@ -0,0 +1,80 @@ +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 { RECORD_STATUS } from 'src/enums'; + +@Injectable() +export class SessionService { + constructor( + @InjectRepository(Session) + private readonly sessionRepository: MongoRepository, + ) {} + + create({ surveyId, userId }) { + const session = this.sessionRepository.create({ + surveyId, + userId, + }); + return this.sessionRepository.save(session); + } + + findOne(sessionId) { + return this.sessionRepository.findOne({ + where: { + _id: new ObjectId(sessionId), + }, + }); + } + + findLatestEditingOne({ surveyId }) { + return this.sessionRepository.findOne({ + where: { + surveyId, + 'curStatus.status': { + $ne: RECORD_STATUS.NEW, + }, + }, + }); + } + + updateSessionToEditing({ sessionId, surveyId }) { + const now = Date.now(); + const editingStatus = { + status: RECORD_STATUS.EDITING, + date: now, + }; + const newStatus = { + status: RECORD_STATUS.NEW, + date: now, + }; + return Promise.all([ + this.sessionRepository.updateOne( + { + _id: new ObjectId(sessionId), + }, + { + $set: { + curStatus: editingStatus, + updateDate: now, + }, + }, + ), + this.sessionRepository.updateMany( + { + surveyId, + _id: { + $ne: new ObjectId(sessionId), + }, + }, + { + $set: { + curStatus: newStatus, + updateDate: now, + }, + }, + ), + ]); + } +} 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..77f0685a 100644 --- a/server/src/modules/survey/template/surveyTemplate/survey/normal.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/normal.json @@ -48,7 +48,8 @@ "mustOthers": false, "othersKey": "", "placeholderDesc": "", - "hash": "115019" + "hash": "115019", + "quota": "0" }, { "text": "选项2", @@ -57,9 +58,11 @@ "mustOthers": false, "othersKey": "", "placeholderDesc": "", - "hash": "115020" + "hash": "115020", + "quota": "0" } - ] + ], + "quotaNoDisplay": false } ] } diff --git a/server/src/modules/survey/template/surveyTemplate/survey/vote.json b/server/src/modules/survey/template/surveyTemplate/survey/vote.json index f8bbc899..34de3afe 100644 --- a/server/src/modules/survey/template/surveyTemplate/survey/vote.json +++ b/server/src/modules/survey/template/surveyTemplate/survey/vote.json @@ -41,8 +41,8 @@ "innerType": "radio", "field": "data606", "title": "标题2", - "minNum": "", - "maxNum": "", + "minNum": 0, + "maxNum": 0, "options": [ { "text": "选项1", diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index b7b1a6eb..6533d6c7 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -20,7 +20,7 @@ import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugi import { RECORD_STATUS } from 'src/enums'; import { SurveyResponse } from 'src/models/surveyResponse.entity'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { UserService } from 'src/modules/auth/services/user.service'; @@ -122,7 +122,7 @@ describe('SurveyResponseController', () => { }, }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { error: jest.fn(), info: jest.fn(), @@ -220,7 +220,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 +268,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 +277,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 +290,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 +306,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 +318,7 @@ describe('SurveyResponseController', () => { .spyOn(responseSchemaService, 'getResponseSchemaByPath') .mockResolvedValueOnce(mockResponseSchema); - await expect(controller.createResponse(reqBody, {})).rejects.toThrow( + await expect(controller.createResponse(reqBody)).rejects.toThrow( HttpException, ); }); @@ -343,7 +344,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 8df092bf..2640eefd 100644 --- a/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts +++ b/server/src/modules/surveyResponse/controllers/responseSchema.controller.ts @@ -13,7 +13,7 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { RECORD_STATUS } from 'src/enums'; import { ApiTags } from '@nestjs/swagger'; import Joi from 'joi'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { SurveyNotFoundException } from 'src/exceptions/surveyNotFoundException'; import { WhitelistType } from 'src/interfaces/survey'; import { UserService } from 'src/modules/auth/services/user.service'; @@ -24,7 +24,7 @@ import { WorkspaceMemberService } from 'src/modules/workspace/services/workspace export class ResponseSchemaController { constructor( private readonly responseSchemaService: ResponseSchemaService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, private readonly userService: UserService, private readonly workspaceMemberService: WorkspaceMemberService, ) {} diff --git a/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts b/server/src/modules/surveyResponse/controllers/surveyResponse.controller.ts index af8193f3..160f9366 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'; @@ -7,37 +7,48 @@ import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { getPushingData } from 'src/utils/messagePushing'; 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 { Logger } from 'src/logger'; + +import { CounterService } from '../services/counter.service'; +import { XiaojuSurveyLogger } 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 logger: Logger, + private readonly counterService: CounterService, + private readonly logger: XiaojuSurveyLogger, + 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); // 校验参数 @@ -53,9 +64,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); } @@ -205,6 +214,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] @@ -214,38 +224,77 @@ export class SurveyResponseController { const arr = cur.options.map((optionItem) => ({ hash: optionItem.hash, text: optionItem.text, + quota: optionItem.quota, })); pre[cur.field] = arr; 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 { + // 使用redis作为锁,校验选项配额 + 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) { + const option = optionTextAndId[field].find( + (opt) => opt['hash'] === val, + ); + const quota = parseInt(option['quota']); + if ( + quota && + optionCountData?.[val] && + quota <= optionCountData[val] + ) { + return { + code: EXCEPTION_CODE.RESPONSE_OVER_LIMIT, + data: { + field, + optionHash: option.hash, + }, + }; + } + 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}`); } // 入库 @@ -259,7 +308,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/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index faf1a86d..335b23dd 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/workspace/_test/workspace.controller.spec.ts b/server/src/modules/workspace/_test/workspace.controller.spec.ts index 9431b6ce..aed91a3b 100644 --- a/server/src/modules/workspace/_test/workspace.controller.spec.ts +++ b/server/src/modules/workspace/_test/workspace.controller.spec.ts @@ -10,7 +10,7 @@ import { Workspace } from 'src/models/workspace.entity'; import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { UserService } from 'src/modules/auth/services/user.service'; import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { User } from 'src/models/user.entity'; jest.mock('src/guards/authentication.guard'); @@ -65,7 +65,7 @@ describe('WorkspaceController', () => { }, }, { - provide: Logger, + provide: XiaojuSurveyLogger, useValue: { info: jest.fn(), error: jest.fn(), diff --git a/server/src/modules/workspace/controllers/workspace.controller.ts b/server/src/modules/workspace/controllers/workspace.controller.ts index 75da6319..d74c9878 100644 --- a/server/src/modules/workspace/controllers/workspace.controller.ts +++ b/server/src/modules/workspace/controllers/workspace.controller.ts @@ -31,7 +31,7 @@ import { import { splitMembers } from '../utils/splitMember'; import { UserService } from 'src/modules/auth/services/user.service'; import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service'; -import { Logger } from 'src/logger'; +import { XiaojuSurveyLogger } from 'src/logger'; import { GetWorkspaceListDto } from '../dto/getWorkspaceList.dto'; import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { Workspace } from 'src/models/workspace.entity'; @@ -46,7 +46,7 @@ export class WorkspaceController { private readonly workspaceMemberService: WorkspaceMemberService, private readonly userService: UserService, private readonly surveyMetaService: SurveyMetaService, - private readonly logger: Logger, + private readonly logger: XiaojuSurveyLogger, ) {} @Get('getRoleList') @@ -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, @@ -137,7 +134,6 @@ export class WorkspaceController { if (error) { this.logger.error( `GetWorkspaceListDto validate failed: ${error.message}`, - { req }, ); throw new HttpException( `参数错误: 请联系管理员`, diff --git a/server/src/utils/xss.ts b/server/src/utils/xss.ts new file mode 100644 index 00000000..b7ea7ee5 --- /dev/null +++ b/server/src/utils/xss.ts @@ -0,0 +1,53 @@ +import xss from 'xss'; + +const myxss = new (xss as any).FilterXSS({ + onIgnoreTagAttr(tag, name, value) { + if (name === 'style' || name === 'class') { + return `${name}="${value}"`; + } + return undefined; + }, + onIgnoreTag(tag, html) { + // 过滤为空,否则不过滤为空 + const re1 = new RegExp('<.+?>', 'g'); + if (re1.test(html)) { + return ''; + } else { + return html; + } + }, +}); + +export const cleanRichTextWithMediaTag = (text) => { + if (!text) { + return text === 0 ? 0 : ''; + } + const html = transformHtmlTag(text) + .replace(//g, '[图片]') + .replace(//g, '[视频]'); + const content = html.replace(/<[^<>]+>/g, '').replace(/ /g, ''); + + return content; +}; + +export function escapeHtml(html) { + return html.replace(//g, '>'); +} +export const transformHtmlTag = (html) => { + if (!html) return ''; + if (typeof html !== 'string') return html + ''; + return html + .replace(html ? /&(?!#?\w+;)/g : /&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\\\n/g, '\\n'); + //.replace(/ /g, "") +}; + +const filterXSSClone = myxss.process.bind(myxss); + +export const filterXSS = (html) => filterXSSClone(transformHtmlTag(html)); + +export const escapeFilterXSS = (html) => escapeHtml(filterXSS(html)); 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..d3275dea 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,7 @@ "build-only": "vite build", "type-check": "vue-tsc --build --force", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", - "format": "prettier --write src/" + "format": "prettier --write src/materials/setters/widgets/QuotaConfig.vue" }, "dependencies": { "@logicflow/core": "2.0.0", @@ -30,6 +30,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", @@ -43,6 +44,7 @@ "@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", diff --git a/web/src/management/App.vue b/web/src/management/App.vue index cc283ad9..bd20b829 100644 --- a/web/src/management/App.vue +++ b/web/src/management/App.vue @@ -2,10 +2,71 @@ - diff --git a/web/src/management/pages/downloadTask/TaskList.vue b/web/src/management/pages/downloadTask/TaskList.vue new file mode 100644 index 00000000..27a31107 --- /dev/null +++ b/web/src/management/pages/downloadTask/TaskList.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/web/src/management/pages/downloadTask/components/DownloadTaskList.vue b/web/src/management/pages/downloadTask/components/DownloadTaskList.vue new file mode 100644 index 00000000..a19384ed --- /dev/null +++ b/web/src/management/pages/downloadTask/components/DownloadTaskList.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..bf98988e 100644 --- a/web/src/management/pages/edit/components/ModuleNavbar.vue +++ b/web/src/management/pages/edit/components/ModuleNavbar.vue @@ -68,15 +68,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 } diff --git a/web/src/management/pages/edit/index.vue b/web/src/management/pages/edit/index.vue index 84e7292f..70808dd3 100644 --- a/web/src/management/pages/edit/index.vue +++ b/web/src/management/pages/edit/index.vue @@ -24,13 +24,16 @@ import LeftMenu from '@/management/components/LeftMenu.vue' import CommonTemplate from './components/CommonTemplate.vue' import Navbar from './components/ModuleNavbar.vue' + const editStore = useEditStore() const { init, setSurveyId } = editStore + const router = useRouter() const route = useRoute() onMounted(async () => { - setSurveyId(route.params.id as string) + const surveyId = route.params.id as string + setSurveyId(surveyId) try { await init() diff --git a/web/src/management/pages/edit/modules/contentModule/PublishPanel.vue b/web/src/management/pages/edit/modules/contentModule/PublishPanel.vue index f7011acd..2b97cb65 100644 --- a/web/src/management/pages/edit/modules/contentModule/PublishPanel.vue +++ b/web/src/management/pages/edit/modules/contentModule/PublishPanel.vue @@ -4,14 +4,15 @@ diff --git a/web/src/render/components/BackAnswerDialog.vue b/web/src/render/components/BackAnswerDialog.vue new file mode 100644 index 00000000..eeafa7ee --- /dev/null +++ b/web/src/render/components/BackAnswerDialog.vue @@ -0,0 +1,79 @@ + + + + \ No newline at end of file diff --git a/web/src/render/components/QuestionWrapper.vue b/web/src/render/components/QuestionWrapper.vue index 155752bb..4152b33e 100644 --- a/web/src/render/components/QuestionWrapper.vue +++ b/web/src/render/components/QuestionWrapper.vue @@ -4,6 +4,7 @@ :moduleConfig="questionConfig" :indexNumber="indexNumber" :showTitle="true" + @input="handleInput" @change="handleChange" > @@ -14,6 +15,7 @@ import QuestionRuleContainer from '../../materials/questions/QuestionRuleContain import { useVoteMap } from '@/render/hooks/useVoteMap' import { useShowOthers } from '@/render/hooks/useShowOthers' import { useShowInput } from '@/render/hooks/useShowInput' +import { useOptionsQuota } from '@/render/hooks/useOptionsQuota' import { cloneDeep } from 'lodash-es' import { useQuestionStore } from '../stores/question' import { useSurveyStore } from '../stores/survey' @@ -49,16 +51,24 @@ const questionConfig = computed(() => { let alloptions = options if (type === QUESTION_TYPE.VOTE) { + // 处理投票进度 const { options, voteTotal } = useVoteMap(field) const voteOptions = unref(options) alloptions = alloptions.map((obj, index) => Object.assign(obj, voteOptions[index])) moduleConfig.voteTotal = unref(voteTotal) } - + if(NORMAL_CHOICES.includes(type) && + options.some(option => option.quota > 0)) { + // 处理普通选择题的选项配额 + let { options: optionWithQuota } = useOptionsQuota(field) + + alloptions = alloptions.map((obj, index) => Object.assign(obj, optionWithQuota[index])) + } if ( NORMAL_CHOICES.includes(type) && - options.filter((optionItem) => optionItem.others).length > 0 + options.some(option => option.others) ) { + // 处理普通选择题的填写更多 let { options, othersValue } = useShowOthers(field) const othersOptions = unref(options) alloptions = alloptions.map((obj, index) => Object.assign(obj, othersOptions[index])) @@ -71,6 +81,7 @@ const questionConfig = computed(() => { Object.keys(rest?.rangeConfig).filter((index) => rest?.rangeConfig[index].isShowInput).length > 0 ) { + // 处理评分题的的选项后输入框 let { rangeConfig, othersValue } = useShowInput(field) moduleConfig.rangeConfig = unref(rangeConfig) moduleConfig.othersValue = unref(othersValue) @@ -126,9 +137,19 @@ const handleChange = (data) => { if (props.moduleConfig.type === QUESTION_TYPE.VOTE) { questionStore.updateVoteData(data) } + // 处理选项配额 + if (props.moduleConfig.type === NORMAL_CHOICES) { + questionStore.updateQuotaData(data) + } + // 断点续答的的数据缓存 + localStorageBack() processJumpSkip() } +const handleInput = () => { + localStorageBack() +} + const processJumpSkip = () => { const targetResult = surveyStore.jumpLogicEngine .getResultsByField(changeField.value, surveyStore.formValues) @@ -169,4 +190,12 @@ const processJumpSkip = () => { .map((item) => item.field) questionStore.addNeedHideFields(skipKey) } +const localStorageBack = () => { + var formData = Object.assign({}, surveyStore.formValues); + + //浏览器存储 + localStorage.removeItem(surveyStore.surveyPath + "_questionData") + localStorage.setItem(surveyStore.surveyPath + "_questionData", JSON.stringify(formData)) + localStorage.setItem('isSubmit', JSON.stringify(false)) +} diff --git a/web/src/render/hooks/useOptionsQuota.js b/web/src/render/hooks/useOptionsQuota.js new file mode 100644 index 00000000..c9d8a688 --- /dev/null +++ b/web/src/render/hooks/useOptionsQuota.js @@ -0,0 +1,23 @@ +import { useQuestionStore } from '../stores/question' +export const useOptionsQuota = (questionKey) => { + const questionStore = useQuestionStore() + const options = questionStore.questionData[questionKey].options.map((option) => { + if(option.quota){ + const optionHash = option.hash + const selectCount = questionStore.quotaMap?.[questionKey]?.[optionHash] || 0 + const release = Number(option.quota) - Number(selectCount) + return { + ...option, + disabled: release === 0, + selectCount, + release + } + } else { + return { + ...option, + } + } + }) + + return { options } +} \ No newline at end of file diff --git a/web/src/render/hooks/useQuestionInfo.ts b/web/src/render/hooks/useQuestionInfo.ts new file mode 100644 index 00000000..dabf8569 --- /dev/null +++ b/web/src/render/hooks/useQuestionInfo.ts @@ -0,0 +1,18 @@ +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/pages/IndexPage.vue b/web/src/render/pages/IndexPage.vue index b54dfa68..94161030 100644 --- a/web/src/render/pages/IndexPage.vue +++ b/web/src/render/pages/IndexPage.vue @@ -2,60 +2,10 @@ diff --git a/web/src/render/pages/RenderPage.vue b/web/src/render/pages/RenderPage.vue index 88a8e78c..f233b23f 100644 --- a/web/src/render/pages/RenderPage.vue +++ b/web/src/render/pages/RenderPage.vue @@ -21,9 +21,9 @@