diff --git a/README.md b/README.md index ddd9e1ab..a9071bc9 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ _**(个人和企业用户均可快速构建特定领域的调研类解决方案 # 技术 -Web 端:Vue3 + ElementPlus;C 端多端渲染(在建,[申请加入共建](https://xiaojusurvey.didi.cn/docs/next/share/%E5%A6%82%E4%BD%95%E5%8F%82%E4%B8%8E%E8%B4%A1%E7%8C%AE#%E6%88%90%E4%B8%BA%E5%AE%98%E6%96%B9%E4%B8%93%E9%A1%B9%E5%BB%BA%E8%AE%BE%E8%80%85)) +Web 端:Vue3 + ElementPlus;C 端多端渲染(规划中) -Server 端:Nestjs + MongoDB;Java(在建,[申请加入共建](https://xiaojusurvey.didi.cn/docs/next/share/%E5%A6%82%E4%BD%95%E5%8F%82%E4%B8%8E%E8%B4%A1%E7%8C%AE#%E6%88%90%E4%B8%BA%E5%AE%98%E6%96%B9%E4%B8%93%E9%A1%B9%E5%BB%BA%E8%AE%BE%E8%80%85)) +Server 端:Nestjs + MongoDB;Java(在建,[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)) -智能化基座:(在建) +智能化基座:(规划中) # 项目优势 @@ -194,18 +194,18 @@ npm run serve

+## 微信交流群(推荐) + +官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。任何问题和合作可以联系小助手: + + + ## QQ 交流群 官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入: [](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419) -## 微信交流群 - -官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。任何问题和合作可以联系小助手: - - - ## Star 开源不易,如果该项目对你有帮助,请 star 一下 ❤️❤️❤️,你的支持是我们最大的动力。 diff --git a/README_EN.md b/README_EN.md index b827ecf7..644fdf4e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -47,17 +47,17 @@ > For more comprehensive features, please refer to the official Feature documentation. - + _**(Both individual and enterprise users can quickly build survey solutions specific to their fields.)**_ # Technology -Web: Vue3 + ElementPlus; Multi-end rendering for C-end (under construction, application for co-construction welcome). +Web: Vue3 + ElementPlus; Multi-end rendering for C-end (planning). -Server: Nestjs + MongoDB; Java (under construction). +Server: Nestjs + MongoDB; Java ([under construction](https://github.com/didi/xiaoju-survey/issues/306)). -Intelligent Foundation: (under construction). +Intelligent Foundation: (planning). # Project Advantages @@ -194,18 +194,18 @@ Create and publish a questionnaire.

-## QQ Group - -The official group will release the latest project news, construction plans, and community activities. Welcome to join: - -[](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419) - ## WeChat Group The official group will release the latest project news, construction plans, and community activities. Any questions and cooperation can contact the assistant: +## QQ Group + +The official group will release the latest project news, construction plans, and community activities. Welcome to join: + +[](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419) + ## Star Open source is not easy. If this project helps you, please star it ❤️❤️❤️. Your support is our greatest motivation. diff --git a/server/src/guards/__test/survey.guard.spec.ts b/server/src/guards/__test/survey.guard.spec.ts index 4c30c453..ccc1e7fd 100644 --- a/server/src/guards/__test/survey.guard.spec.ts +++ b/server/src/guards/__test/survey.guard.spec.ts @@ -11,6 +11,7 @@ import { NoPermissionException } from 'src/exceptions/noPermissionException'; import { SurveyMeta } from 'src/models/surveyMeta.entity'; import { WorkspaceMember } from 'src/models/workspaceMember.entity'; import { Collaborator } from 'src/models/collaborator.entity'; +import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; describe('SurveyGuard', () => { let guard: SurveyGuard; @@ -81,7 +82,19 @@ describe('SurveyGuard', () => { ); }); - it('should allow access if user is the owner of the survey', async () => { + it('should allow access if user is the owner of the survey by ownerId', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { ownerId: 'testUserId', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should allow access if user is the owner of the survey by username', async () => { const context = createMockExecutionContext(); const surveyMeta = { owner: 'testUser', workspaceId: null }; jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); @@ -108,7 +121,35 @@ describe('SurveyGuard', () => { expect(result).toBe(true); }); - it('should throw NoPermissionException if user has no permissions', async () => { + it('should throw NoPermissionException if user is not a workspace member', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' }; + jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId'); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should throw NoPermissionException if no permissions are provided', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); + jest.spyOn(reflector, 'get').mockReturnValueOnce(null); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + await expect(guard.canActivate(context)).rejects.toThrow( + NoPermissionException, + ); + }); + + it('should throw NoPermissionException if user has no matching permissions', async () => { const context = createMockExecutionContext(); const surveyMeta = { owner: 'anotherUser', workspaceId: null }; jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); @@ -125,6 +166,24 @@ describe('SurveyGuard', () => { ); }); + it('should allow access if user has the required permissions', async () => { + const context = createMockExecutionContext(); + const surveyMeta = { owner: 'anotherUser', workspaceId: null }; + jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId'); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce([SURVEY_PERMISSION.SURVEY_CONF_MANAGE]); + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(collaboratorService, 'getCollaborator').mockResolvedValue({ + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + } as Collaborator); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + function createMockExecutionContext(): ExecutionContext { return { switchToHttp: jest.fn().mockReturnValue({ diff --git a/server/src/modules/auth/__test/user.service.spec.ts b/server/src/modules/auth/__test/user.service.spec.ts index a3bcc058..e0cdfc19 100644 --- a/server/src/modules/auth/__test/user.service.spec.ts +++ b/server/src/modules/auth/__test/user.service.spec.ts @@ -6,6 +6,7 @@ import { User } from 'src/models/user.entity'; import { HttpException } from 'src/exceptions/httpException'; import { hash256 } from 'src/utils/hash256'; import { RECORD_STATUS } from 'src/enums'; +import { ObjectId } from 'mongodb'; describe('UserService', () => { let service: UserService; @@ -21,6 +22,7 @@ describe('UserService', () => { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), + find: jest.fn(), }, }, ], @@ -32,6 +34,10 @@ describe('UserService', () => { ); }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + it('should create a user', async () => { const userInfo = { username: 'testUser', @@ -102,7 +108,7 @@ describe('UserService', () => { expect(user).toEqual({ ...userInfo, password: hashedPassword }); }); - it('should return undefined when user is not found by credentials', async () => { + it('should return null when user is not found by credentials', async () => { const userInfo = { username: 'nonExistingUser', password: 'nonExistingPassword', @@ -129,7 +135,8 @@ describe('UserService', () => { const userInfo = { username: username, password: 'existingPassword', - } as User; + curStatus: { status: 'ACTIVE' }, + } as unknown as User; jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); @@ -137,10 +144,129 @@ describe('UserService', () => { expect(userRepository.findOne).toHaveBeenCalledWith({ where: { - 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, username: username, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, }, }); expect(user).toEqual(userInfo); }); + + it('should return null when user is not found by username', async () => { + const username = 'nonExistingUser'; + + const findOneSpy = jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(null); + + const user = await service.getUserByUsername(username); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { + username: username, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toBe(null); + }); + + it('should return a user by id', async () => { + const id = '60c72b2f9b1e8a5f4b123456'; + const userInfo = { + _id: new ObjectId(id), + username: 'testUser', + curStatus: { status: 'ACTIVE' }, + } as unknown as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo); + + const user = await service.getUserById(id); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toEqual(userInfo); + }); + + it('should return null when user is not found by id', async () => { + const id = '60c72b2f9b1e8a5f4b123456'; + + const findOneSpy = jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(null); + + const user = await service.getUserById(id); + + expect(findOneSpy).toHaveBeenCalledWith({ + where: { + _id: new ObjectId(id), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + }); + expect(user).toBe(null); + }); + + it('should return a list of users by username', async () => { + const username = 'test'; + const userList = [ + { _id: new ObjectId(), username: 'testUser1', createDate: new Date() }, + { _id: new ObjectId(), username: 'testUser2', createDate: new Date() }, + ]; + + jest + .spyOn(userRepository, 'find') + .mockResolvedValue(userList as unknown as User[]); + + const result = await service.getUserListByUsername({ + username, + skip: 0, + take: 10, + }); + + expect(userRepository.find).toHaveBeenCalledWith({ + where: { + username: new RegExp(username), + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + skip: 0, + take: 10, + select: ['_id', 'username', 'createDate'], + }); + expect(result).toEqual(userList); + }); + + it('should return a list of users by ids', async () => { + const idList = ['60c72b2f9b1e8a5f4b123456', '60c72b2f9b1e8a5f4b123457']; + const userList = [ + { + _id: new ObjectId(idList[0]), + username: 'testUser1', + createDate: new Date(), + }, + { + _id: new ObjectId(idList[1]), + username: 'testUser2', + createDate: new Date(), + }, + ]; + + jest + .spyOn(userRepository, 'find') + .mockResolvedValue(userList as unknown as User[]); + + const result = await service.getUserListByIds({ idList }); + + expect(userRepository.find).toHaveBeenCalledWith({ + where: { + _id: { + $in: idList.map((id) => new ObjectId(id)), + }, + 'curStatus.status': { $ne: RECORD_STATUS.REMOVED }, + }, + select: ['_id', 'username', 'createDate'], + }); + expect(result).toEqual(userList); + }); }); diff --git a/server/src/modules/survey/__test/collaborator.controller.spec.ts b/server/src/modules/survey/__test/collaborator.controller.spec.ts index 79819401..0119aaa3 100644 --- a/server/src/modules/survey/__test/collaborator.controller.spec.ts +++ b/server/src/modules/survey/__test/collaborator.controller.spec.ts @@ -10,7 +10,13 @@ import { UserService } from 'src/modules/auth/services/user.service'; import { ObjectId } from 'mongodb'; import { SurveyMetaService } from '../services/surveyMeta.service'; import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service'; -import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; +import { + SURVEY_PERMISSION, + SURVEY_PERMISSION_DESCRIPTION, +} from 'src/enums/surveyPermission'; +import { BatchSaveCollaboratorDto } from '../dto/batchSaveCollaborator.dto'; +import { User } from 'src/models/user.entity'; +import { SurveyMeta } from 'src/models/surveyMeta.entity'; jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); @@ -21,6 +27,8 @@ describe('CollaboratorController', () => { let collaboratorService: CollaboratorService; let logger: Logger; let userService: UserService; + let surveyMetaService: SurveyMetaService; + let workspaceMemberServie: WorkspaceMemberService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -34,12 +42,18 @@ describe('CollaboratorController', () => { changeUserPermission: jest.fn(), deleteCollaborator: jest.fn(), getCollaborator: jest.fn(), + batchDeleteBySurveyId: jest.fn(), + batchCreate: jest.fn(), + batchDelete: jest.fn(), + updateById: jest.fn(), + batchSaveCollaborator: jest.fn(), }, }, { provide: Logger, useValue: { error: jest.fn(), + info: jest.fn(), }, }, { @@ -72,6 +86,10 @@ describe('CollaboratorController', () => { collaboratorService = module.get(CollaboratorService); logger = module.get(Logger); userService = module.get(UserService); + surveyMetaService = module.get(SurveyMetaService); + workspaceMemberServie = module.get( + WorkspaceMemberService, + ); }); it('should be defined', () => { @@ -115,6 +133,59 @@ describe('CollaboratorController', () => { HttpException, ); }); + + it('should throw an exception if user does not exist', async () => { + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: new ObjectId().toString(), + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { + user: { _id: 'userId' }, + surveyMeta: { ownerId: new ObjectId().toString() }, + }; + + jest.spyOn(userService, 'getUserById').mockResolvedValue(null); + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an exception if user is the survey owner', async () => { + const userId = new ObjectId().toString(); + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: userId, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { user: { _id: 'userId' }, surveyMeta: { ownerId: userId } }; + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); + + it('should throw an exception if user is already a collaborator', async () => { + const userId = new ObjectId().toString(); + const reqBody: CreateCollaboratorDto = { + surveyId: 'surveyId', + userId: userId, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }; + const req = { + user: { _id: 'userId' }, + surveyMeta: { ownerId: new ObjectId().toString() }, + }; + + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue({} as unknown as Collaborator); + + await expect(controller.addCollaborator(reqBody, req)).rejects.toThrow( + HttpException, + ); + }); }); describe('getSurveyCollaboratorList', () => { @@ -217,4 +288,229 @@ describe('CollaboratorController', () => { expect(logger.error).toHaveBeenCalledTimes(1); }); }); + + // 新增的测试方法 + describe('getPermissionList', () => { + it('should return the permission list', async () => { + const result = Object.values(SURVEY_PERMISSION_DESCRIPTION); + + const response = await controller.getPermissionList(); + + expect(response).toEqual({ + code: 200, + data: result, + }); + }); + }); + + describe('batchSaveCollaborator', () => { + it('should batch save collaborators successfully', async () => { + const userId0 = new ObjectId().toString(); + const userId1 = new ObjectId().toString(); + const existsCollaboratorId = new ObjectId().toString(); + const surveyId = new ObjectId().toString(); + const reqBody: BatchSaveCollaboratorDto = { + surveyId: surveyId, + collaborators: [ + { + userId: userId0, + permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE], + }, + { + _id: existsCollaboratorId, + userId: userId1, + permissions: [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE], + }, + ], + }; + const req = { + user: { _id: 'requestUserId' }, + surveyMeta: { ownerId: 'ownerId' }, + }; + + const userList = [ + { _id: new ObjectId(userId0) }, + { _id: new ObjectId(userId1) }, + ]; + + jest + .spyOn(userService, 'getUserListByIds') + .mockResolvedValue(userList as unknown as User[]); + jest + .spyOn(collaboratorService, 'batchDelete') + .mockResolvedValue({ deletedCount: 1, acknowledged: true }); + jest + .spyOn(collaboratorService, 'batchCreate') + .mockResolvedValue([{}] as any); + jest.spyOn(collaboratorService, 'updateById').mockResolvedValue({}); + + const response = await controller.batchSaveCollaborator(reqBody, req); + + expect(response).toEqual({ + code: 200, + }); + expect(userService.getUserListByIds).toHaveBeenCalled(); + expect(collaboratorService.batchDelete).toHaveBeenCalled(); + expect(collaboratorService.batchCreate).toHaveBeenCalled(); + expect(collaboratorService.updateById).toHaveBeenCalled(); + }); + + it('should throw an exception if validation fails', async () => { + const reqBody: BatchSaveCollaboratorDto = { + surveyId: '', + collaborators: [ + { + userId: '', + permissions: [SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE], + }, + ], + }; + const req = { user: { _id: 'userId' } }; + + await expect( + controller.batchSaveCollaborator(reqBody, req), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('getUserSurveyPermissions', () => { + it('should return owner permissions if user is the owner', async () => { + const req = { + user: { _id: new ObjectId(), username: 'owner' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: req.user._id.toString(), + owner: req.user.username, + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: true, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }); + }); + + it('should return default permissions if user is a workspace member', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue({} as any); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: [ + SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE, + SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, + SURVEY_PERMISSION.SURVEY_CONF_MANAGE, + ], + }, + }); + }); + + it('should return collaborator permissions if user is a collaborator', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + const collaborator = { + permissions: ['read', 'write'], + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null); + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue(collaborator as Collaborator); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: collaborator.permissions, + }, + }); + }); + + it('should return empty permissions if user has no permissions', async () => { + const req = { + user: { _id: new ObjectId(), username: 'user' }, + }; + const query = { surveyId: 'surveyId' }; + const surveyMeta = { + ownerId: 'ownerId', + owner: 'owner', + workspaceId: 'workspaceId', + }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValue(surveyMeta as SurveyMeta); + jest.spyOn(workspaceMemberServie, 'findOne').mockResolvedValue(null); + jest + .spyOn(collaboratorService, 'getCollaborator') + .mockResolvedValue(null); + + const response = await controller.getUserSurveyPermissions(req, query); + + expect(response).toEqual({ + code: 200, + data: { + isOwner: false, + permissions: [], + }, + }); + }); + + it('should throw an exception if survey does not exist', async () => { + const req = { user: { _id: 'userId' } }; + const query = { surveyId: 'nonexistentSurveyId' }; + + jest + .spyOn(surveyMetaService, 'getSurveyById') + .mockResolvedValueOnce(null); + + await expect( + controller.getUserSurveyPermissions(req, query), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts index a3222029..a2ca640f 100644 --- a/server/src/modules/survey/__test/dataStatistic.controller.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.controller.spec.ts @@ -14,6 +14,7 @@ import { Logger } from 'src/logger'; import { UserService } from 'src/modules/auth/services/user.service'; import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin'; import { AuthService } from 'src/modules/auth/services/auth.service'; +import { HttpException } from 'src/exceptions/httpException'; jest.mock('../services/dataStatistic.service'); jest.mock('../services/surveyMeta.service'); @@ -21,11 +22,13 @@ jest.mock('../../surveyResponse/services/responseScheme.service'); jest.mock('src/guards/authentication.guard'); jest.mock('src/guards/survey.guard'); -jest.mock('src/guards/workspace.guard'); describe('DataStatisticController', () => { let controller: DataStatisticController; let dataStatisticService: DataStatisticService; + let responseSchemaService: ResponseSchemaService; + let pluginManager: XiaojuSurveyPluginManager; + let logger: Logger; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -64,9 +67,14 @@ describe('DataStatisticController', () => { controller = module.get(DataStatisticController); dataStatisticService = module.get(DataStatisticService); - const pluginManager = module.get( + responseSchemaService = module.get( + ResponseSchemaService, + ); + pluginManager = module.get( XiaojuSurveyPluginManager, ); + logger = module.get(Logger); + pluginManager.registerPlugin( new ResponseSecurityPlugin('dataAesEncryptSecretKey'), ); @@ -82,6 +90,9 @@ describe('DataStatisticController', () => { const mockRequest = { query: { surveyId, + isDesensitive: false, + page: 1, + pageSize: 10, }, user: { username: 'testUser', @@ -105,13 +116,13 @@ describe('DataStatisticController', () => { }; jest - .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); jest .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, {}); + const result = await controller.data(mockRequest.query, mockRequest); expect(result).toEqual({ code: 200, @@ -125,6 +136,8 @@ describe('DataStatisticController', () => { query: { surveyId, isDesensitive: true, + page: 1, + pageSize: 10, }, user: { username: 'testUser', @@ -146,19 +159,499 @@ describe('DataStatisticController', () => { { difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' }, ], }; + jest - .spyOn(controller['responseSchemaService'], 'getResponseSchemaByPageId') + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') .mockResolvedValueOnce({} as any); jest .spyOn(dataStatisticService, 'getDataTable') .mockResolvedValueOnce(mockDataTable); - const result = await controller.data(mockRequest.query, {}); + const result = await controller.data(mockRequest.query, mockRequest); expect(result).toEqual({ code: 200, data: mockDataTable, }); }); + + it('should throw an exception if validation fails', async () => { + const mockRequest = { + query: { + surveyId: '', + }, + user: { + username: 'testUser', + }, + }; + + await expect( + controller.data(mockRequest.query, mockRequest), + ).rejects.toThrow(HttpException); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + }); + + describe('aggregationStatis', () => { + it('should return aggregation statistics', async () => { + const mockRequest = { + query: { + surveyId: new ObjectId().toString(), + }, + }; + + const mockResponseSchema = { + _id: new ObjectId('6659c3283b1cb279bc2e2b0c'), + curStatus: { + status: 'published', + date: 1717159136024, + }, + statusList: [ + { + status: 'published', + date: 1717158851823, + }, + ], + createDate: 1717158851823, + updateDate: 1717159136025, + title: '问卷调研', + surveyPath: 'ZdGNzTTR', + code: { + bannerConf: { + titleConfig: { + mainTitle: + '

欢迎填写问卷

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

', + subTitle: '', + applyTitle: '', + }, + bannerConfig: { + bgImage: '/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.webp', + bgImageAllowJump: false, + bgImageJumpLink: '', + videoLink: '', + postImg: '', + }, + }, + baseConf: { + begTime: '2024-05-31 20:31:36', + endTime: '2034-05-31 20:31:36', + language: 'chinese', + showVoteProcess: 'allow', + tLimit: 0, + answerBegTime: '00:00:00', + answerEndTime: '23:59:59', + answerLimitTime: 0, + }, + bottomConf: { + logoImage: '/imgs/Logo.webp', + logoImageWidth: '60%', + }, + skinConf: { + backgroundConf: { + color: '#fff', + }, + themeConf: { + color: '#ffa600', + }, + contentConf: { + opacity: 100, + }, + skinColor: '#4a4c5b', + inputBgColor: '#ffffff', + }, + submitConf: { + submitTitle: '提交', + msgContent: { + msg_200: '提交成功', + msg_9001: '您来晚了,感谢支持问卷~', + msg_9002: '请勿多次提交!', + msg_9003: '您来晚了,已经满额!', + msg_9004: '提交失败!', + }, + confirmAgain: { + is_again: true, + again_text: '确认要提交吗?', + }, + link: '', + }, + logicConf: { + showLogicConf: [], + }, + dataConf: { + dataList: [ + { + isRequired: true, + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio', + placeholderDesc: '', + field: 'data515', + title: '标题2', + placeholder: '', + randomSort: false, + checked: false, + minNum: '', + maxNum: '', + options: [ + { + text: '选项1', + imageUrl: '', + others: false, + mustOthers: false, + othersKey: '', + placeholderDesc: '', + hash: '115019', + }, + { + text: '选项2', + imageUrl: '', + others: false, + mustOthers: false, + othersKey: '', + placeholderDesc: '', + hash: '115020', + }, + ], + importKey: 'single', + importData: '', + cOption: '', + cOptions: [], + star: 5, + exclude: false, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data893', + showIndex: true, + showType: true, + showSpliter: true, + type: 'checkbox', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题2', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '466671', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '095415', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data820', + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio-nps', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题3', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '268884', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '371166', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + { + field: 'data549', + showIndex: true, + showType: true, + showSpliter: true, + type: 'radio-star', + placeholderDesc: '', + sLimit: 0, + mhLimit: 0, + title: '标题4', + placeholder: '', + valid: '', + isRequired: true, + randomSort: false, + showLeftNum: true, + innerRandom: false, + checked: false, + selectType: 'radio', + sortWay: 'v', + noNps: '', + minNum: '', + maxNum: '', + starStyle: 'star', + starMin: 1, + starMax: 5, + min: 0, + max: 10, + minMsg: '极不满意', + maxMsg: '十分满意', + rangeConfig: {}, + options: [ + { + text: '选项1', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '274183', + }, + { + text: '选项2', + others: false, + othersKey: '', + placeholderDesc: '', + hash: '842967', + }, + ], + star: 5, + optionOrigin: '', + originType: 'selected', + matrixOptionsRely: '', + numberRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '1000', + value: 1000, + }, + }, + textRange: { + min: { + placeholder: '0', + value: 0, + }, + max: { + placeholder: '500', + value: 500, + }, + }, + }, + ], + }, + }, + pageId: '6659c3283b1cb279bc2e2b0c', + }; + + const mockAggregationResult = [ + { + field: 'data515', + data: { + aggregation: [ + { + id: '115019', + count: 1, + }, + { + id: '115020', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data893', + data: { + aggregation: [ + { + id: '466671', + count: 2, + }, + { + id: '095415', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data820', + data: { + aggregation: [ + { + id: '8', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + { + field: 'data549', + data: { + aggregation: [ + { + id: '5', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + ]; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') + .mockResolvedValueOnce(mockResponseSchema as any); + jest + .spyOn(dataStatisticService, 'aggregationStatis') + .mockResolvedValueOnce(mockAggregationResult); + + const result = await controller.aggregationStatis(mockRequest.query); + + expect(result).toEqual({ + code: 200, + data: expect.any(Array), + }); + }); + + it('should throw an exception if validation fails', async () => { + const mockRequest = { + query: { + surveyId: '', + }, + }; + + await expect( + controller.aggregationStatis(mockRequest.query), + ).rejects.toThrow(HttpException); + }); + + it('should return empty data if response schema does not exist', async () => { + const mockRequest = { + query: { + surveyId: new ObjectId().toString(), + }, + }; + + jest + .spyOn(responseSchemaService, 'getResponseSchemaByPageId') + .mockResolvedValueOnce(null); + + const result = await controller.aggregationStatis(mockRequest.query); + + expect(result).toEqual({ + code: 200, + data: [], + }); + }); }); }); diff --git a/server/src/modules/survey/__test/dataStatistic.service.spec.ts b/server/src/modules/survey/__test/dataStatistic.service.spec.ts index 830c7b59..1130364f 100644 --- a/server/src/modules/survey/__test/dataStatistic.service.spec.ts +++ b/server/src/modules/survey/__test/dataStatistic.service.spec.ts @@ -197,7 +197,6 @@ describe('DataStatisticService', () => { data413_3: expect.any(String), data413: expect.any(Number), data863: expect.any(String), - data413_custom: expect.any(String), difTime: expect.any(String), createDate: expect.any(String), }), @@ -310,4 +309,161 @@ describe('DataStatisticService', () => { ); }); }); + + describe('aggregationStatis', () => { + it('should return correct aggregation data', async () => { + const surveyId = '65afc62904d5db18534c0f78'; + const mockAggregationResult = { + data515: [ + { + count: 1, + data: { + data515: '115019', + }, + }, + { + count: 1, + data: { + data515: '115020', + }, + }, + ], + data893: [ + { + count: 1, + data: { + data893: ['466671'], + }, + }, + { + count: 1, + data: { + data893: ['466671', '095415'], + }, + }, + ], + data820: [ + { + count: 1, + data: { + data820: 8, + }, + }, + ], + data549: [ + { + count: 1, + data: { + data549: 5, + }, + }, + ], + }; + + const fieldList = Object.keys(mockAggregationResult); + + jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({ + next: jest.fn().mockResolvedValue(mockAggregationResult), + } as any); + + const result = await service.aggregationStatis({ + surveyId, + fieldList, + }); + + expect(result).toEqual( + expect.arrayContaining([ + { + field: 'data515', + data: { + aggregation: [ + { + id: '115019', + count: 1, + }, + { + id: '115020', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data893', + data: { + aggregation: [ + { + id: '466671', + count: 2, + }, + { + id: '095415', + count: 1, + }, + ], + submitionCount: 2, + }, + }, + { + field: 'data820', + data: { + aggregation: [ + { + id: '8', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + { + field: 'data549', + data: { + aggregation: [ + { + id: '5', + count: 1, + }, + ], + submitionCount: 1, + }, + }, + ]), + ); + }); + + it('should return empty aggregation data when no responses', async () => { + const surveyId = '65afc62904d5db18534c0f78'; + const fieldList = ['data458', 'data515']; + + jest.spyOn(surveyResponseRepository, 'aggregate').mockReturnValue({ + next: jest.fn().mockResolvedValue({}), + } as any); + + const result = await service.aggregationStatis({ + surveyId, + fieldList, + }); + + expect(result).toEqual( + expect.arrayContaining([ + { + field: 'data458', + data: { + aggregation: [], + submitionCount: 0, + }, + }, + { + field: 'data515', + data: { + aggregation: [], + submitionCount: 0, + }, + }, + ]), + ); + }); + }); }); diff --git a/server/src/modules/survey/controllers/collaborator.controller.ts b/server/src/modules/survey/controllers/collaborator.controller.ts index 35fc1d11..08c97068 100644 --- a/server/src/modules/survey/controllers/collaborator.controller.ts +++ b/server/src/modules/survey/controllers/collaborator.controller.ts @@ -319,6 +319,7 @@ export class CollaboratorController { const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId }); if (!surveyMeta) { + this.logger.error(`问卷不存在: ${surveyId}`, { req }); 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 a0b1754c..452b4fc6 100644 --- a/server/src/modules/survey/controllers/dataStatistic.controller.ts +++ b/server/src/modules/survey/controllers/dataStatistic.controller.ts @@ -20,6 +20,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; import { Logger } from 'src/logger'; import { HttpException } from 'src/exceptions/httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { AggregationStatisDto } from '../dto/aggregationStatis.dto'; +import { handleAggretionData } from '../utils'; @ApiTags('survey') @ApiBearerAuth() @@ -80,4 +82,50 @@ export class DataStatisticController { }, }; } + + @Get('/aggregationStatis') + @HttpCode(200) + @UseGuards(Authentication) + async aggregationStatis(@Query() queryInfo: AggregationStatisDto) { + // 聚合统计 + const { value, error } = AggregationStatisDto.validate(queryInfo); + if (error) { + throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR); + } + const responseSchema = + await this.responseSchemaService.getResponseSchemaByPageId( + value.surveyId, + ); + if (!responseSchema) { + return { + code: 200, + data: [], + }; + } + const allowQuestionType = [ + 'radio', + 'checkbox', + 'binary-choice', + 'radio-star', + 'radio-nps', + 'vote', + ]; + const fieldList = responseSchema.code.dataConf.dataList + .filter((item) => allowQuestionType.includes(item.type)) + .map((item) => item.field); + const dataMap = responseSchema.code.dataConf.dataList.reduce((pre, cur) => { + pre[cur.field] = cur; + return pre; + }, {}); + const res = await this.dataStatisticService.aggregationStatis({ + surveyId: value.surveyId, + fieldList, + }); + return { + code: 200, + data: res.map((item) => { + return handleAggretionData({ item, dataMap }); + }), + }; + } } diff --git a/server/src/modules/survey/dto/aggregationStatis.dto.ts b/server/src/modules/survey/dto/aggregationStatis.dto.ts new file mode 100644 index 00000000..a8f747a7 --- /dev/null +++ b/server/src/modules/survey/dto/aggregationStatis.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import Joi from 'joi'; + +export class AggregationStatisDto { + @ApiProperty({ description: '问卷id', required: true }) + surveyId: string; + + static validate(data) { + return Joi.object({ + surveyId: Joi.string().required(), + }).validate(data); + } +} diff --git a/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts index 1ae04787..119fe584 100644 --- a/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts +++ b/server/src/modules/survey/dto/batchSaveCollaborator.dto.ts @@ -3,7 +3,10 @@ import Joi from 'joi'; import { SURVEY_PERMISSION } from 'src/enums/surveyPermission'; export class CollaboratorDto { - @ApiProperty({ description: '用户id', required: false }) + @ApiProperty({ description: '协作id', required: false }) + _id?: string; + + @ApiProperty({ description: '用户id', required: true }) userId: string; @ApiProperty({ @@ -16,7 +19,7 @@ export class CollaboratorDto { SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE, ], }) - permissions: Array; + permissions: Array; } export class BatchSaveCollaboratorDto { diff --git a/server/src/modules/survey/services/dataStatistic.service.ts b/server/src/modules/survey/services/dataStatistic.service.ts index cd958a7f..ce31fed6 100644 --- a/server/src/modules/survey/services/dataStatistic.service.ts +++ b/server/src/modules/survey/services/dataStatistic.service.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import { keyBy } from 'lodash'; import { DataItem } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; -import { getListHeadByDataList } from '../utils'; +import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils'; @Injectable() export class DataStatisticService { private radioType = ['radio-star', 'radio-nps']; @@ -101,4 +101,62 @@ export class DataStatisticService { listBody, }; } + + async aggregationStatis({ surveyId, fieldList }) { + const $facet = fieldList.reduce((pre, cur) => { + const $match = { $match: { [`data.${cur}`]: { $nin: [[], '', null] } } }; + const $group = { $group: { _id: `$data.${cur}`, count: { $sum: 1 } } }; + const $project = { + $project: { + _id: 0, + count: 1, + secretKeys: 1, + sensitiveKeys: 1, + [`data.${cur}`]: '$_id', + }, + }; + pre[cur] = [$match, $group, $project]; + return pre; + }, {}); + const aggregation = this.surveyResponseRepository.aggregate( + [ + { + $match: { + pageId: surveyId, + 'curStatus.status': { + $ne: 'removed', + }, + }, + }, + { $facet }, + ], + { maxTimeMS: 30000, allowDiskUse: true }, + ); + const res = await aggregation.next(); + const submitionCountMap: Record = {}; + for (const field in res) { + let count = 0; + if (Array.isArray(res[field])) { + for (const optionItem of res[field]) { + count += optionItem.count; + } + } + submitionCountMap[field] = count; + } + const transformedData = transformAndMergeArrayFields(res); + return fieldList.map((field) => { + return { + field, + data: { + aggregation: (transformedData?.[field] || []).map((optionItem) => { + return { + id: optionItem.data[field], + count: optionItem.count, + }; + }), + submitionCount: submitionCountMap?.[field] || 0, + }, + }; + }); + } } diff --git a/server/src/modules/survey/utils/index.ts b/server/src/modules/survey/utils/index.ts index fd988f55..ced78f14 100644 --- a/server/src/modules/survey/utils/index.ts +++ b/server/src/modules/survey/utils/index.ts @@ -66,3 +66,177 @@ export function getListHeadByDataList(dataList) { }); return listHead; } + +export function transformAndMergeArrayFields(data) { + const transformedData = {}; + + for (const key in data) { + const valueMap: Record = {}; + + for (const entry of data[key]) { + const nestedDataKey = Object.keys(entry.data)[0]; + const nestedDataValue = entry.data[nestedDataKey]; + + if (Array.isArray(nestedDataValue)) { + for (const value of nestedDataValue) { + if (!valueMap[value]) { + valueMap[value] = 0; + } + valueMap[value] += entry.count; + } + } else { + if (!valueMap[nestedDataValue]) { + valueMap[nestedDataValue] = 0; + } + valueMap[nestedDataValue] += entry.count; + } + } + + transformedData[key] = Object.keys(valueMap).map((value) => ({ + count: valueMap[value], + data: { + [key]: value, + }, + })); + } + + return transformedData; +} + +export function handleAggretionData({ dataMap, item }) { + const type = dataMap[item.field].type; + const aggregationMap = item.data.aggregation.reduce((pre, cur) => { + pre[cur.id] = cur; + return pre; + }, {}); + if (['radio', 'checkbox', 'vote', 'binary-choice'].includes(type)) { + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + data: { + ...item.data, + aggregation: dataMap[item.field].options.map((optionItem) => { + return { + id: optionItem.hash, + text: optionItem.text, + count: aggregationMap[optionItem.hash]?.count || 0, + }; + }), + }, + }; + } else if (['radio-star', 'radio-nps'].includes(type)) { + const summary: Record = {}; + const average = getAverage({ aggregation: item.data.aggregation }); + const median = getMedian({ aggregation: item.data.aggregation }); + const variance = getVariance({ + aggregation: item.data.aggregation, + average, + }); + summary['average'] = average; + summary['median'] = median; + summary['variance'] = variance; + if (type === 'radio-nps') { + summary['nps'] = getNps({ aggregation: item.data.aggregation }); + } + const range = type === 'radio-nps' ? [0, 10] : [1, 5]; + const arr = []; + for (let i = range[0]; i <= range[1]; i++) { + arr.push(i); + } + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + data: { + aggregation: arr.map((item) => { + const num = item.toString(); + return { + text: num, + id: num, + count: aggregationMap?.[num]?.count || 0, + }; + }), + submitionCount: item.data.submitionCount, + summary, + }, + }; + } else { + return { + ...item, + title: dataMap[item.field].title, + type: dataMap[item.field].type, + }; + } +} + +function getAverage({ aggregation }) { + const { sum, count } = aggregation.reduce( + (pre, cur) => { + const num = parseInt(cur.id); + pre.sum += num * cur.count; + pre.count += cur.count; + return pre; + }, + { sum: 0, count: 0 }, + ); + if (count === 0) { + return 0; + } + return (sum / count).toFixed(2); +} + +function getMedian({ aggregation }) { + const sortedArr = aggregation.sort((a, b) => { + return parseInt(a.id) - parseInt(b.id); + }); + const resArr = []; + for (const item of sortedArr) { + const tmp = new Array(item.count).fill(parseInt(item.id)); + resArr.push(...tmp); + } + if (resArr.length === 0) { + return 0; + } + if (resArr.length % 2 === 1) { + const midIndex = Math.floor(resArr.length / 2); + return resArr[midIndex].toFixed(2); + } + const rightIndex = resArr.length / 2; + const leftIndex = rightIndex - 1; + return ((resArr[leftIndex] + resArr[rightIndex]) / 2).toFixed(2); +} + +function getVariance({ aggregation, average }) { + const { sum, count } = aggregation.reduce( + (pre, cur) => { + const sub = Number(cur.id) - average; + pre.sum += sub * sub; + pre.count += cur.count; + return pre; + }, + { sum: 0, count: 0 }, + ); + if (count === 0 || count === 1) { + return '0.00'; + } + return (sum / (count - 1)).toFixed(2); +} + +function getNps({ aggregation }) { + // 净推荐值(NPS)=(推荐者数/总样本数)×100%-(贬损者数/总样本数)×100% + // 0~10分举例子:推荐者(9-10分);被动者(7-8分);贬损者(0-6分) + let recommand = 0, + derogatory = 0, + total = 0; + for (const item of aggregation) { + const num = parseInt(item.id); + if (num >= 9) { + recommand += item.count; + } else if (num <= 6) { + derogatory += item.count; + } + total += item.count; + } + return ((recommand / total - derogatory / total) * 100).toFixed(2) + '%'; +} diff --git a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts index a6894a36..ddc9ebe4 100644 --- a/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts +++ b/server/src/modules/surveyResponse/__test/surveyResponse.controller.spec.ts @@ -25,7 +25,6 @@ import { WhitelistService } from 'src/modules/auth/services/whitelist.service'; import { MemberType, WhitelistType } from 'src/interfaces/survey'; import { ResponseSchema } from 'src/models/responseSchema.entity'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -import { WhitelistVerify } from 'src/models/whitelistVerify.entity'; const mockDecryptErrorBody = { surveyPath: 'EBzdmnSp', diff --git a/web/components.d.ts b/web/components.d.ts index c897a9e0..54975223 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElMenu: typeof import('element-plus/es')['ElMenu'] @@ -29,6 +30,7 @@ declare module 'vue' { ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRow: typeof import('element-plus/es')['ElRow'] + ElSegmented: typeof import('element-plus/es')['ElSegmented'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelectV2: typeof import('element-plus/es')['ElSelectV2'] ElSlider: typeof import('element-plus/es')['ElSlider'] @@ -40,6 +42,7 @@ declare module 'vue' { ElTag: typeof import('element-plus/es')['ElTag'] ElTimePicker: typeof import('element-plus/es')['ElTimePicker'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElTree: typeof import('element-plus/es')['ElTree'] IEpBottom: typeof import('~icons/ep/bottom')['default'] IEpCheck: typeof import('~icons/ep/check')['default'] IEpCirclePlus: typeof import('~icons/ep/circle-plus')['default'] diff --git a/web/package.json b/web/package.json index 3cc9e456..114191e1 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "axios": "^1.4.0", "clipboard": "^2.0.11", "crypto-js": "^4.2.0", + "echarts": "^5.5.0", "element-plus": "^2.7.0", "lodash-es": "^4.17.21", "moment": "^2.29.4", diff --git a/web/src/management/api/analysis.js b/web/src/management/api/analysis.js index 8e55a48a..5f211572 100644 --- a/web/src/management/api/analysis.js +++ b/web/src/management/api/analysis.js @@ -8,3 +8,11 @@ export const getRecycleList = (data) => { } }) } + +export const getStatisticList = (data) => { + return axios.get('/survey/dataStatistic/aggregationStatis', { + params: { + ...data + } + }) +} diff --git a/web/src/management/config/analysisConfig.js b/web/src/management/config/analysisConfig.js new file mode 100644 index 00000000..c0957837 --- /dev/null +++ b/web/src/management/config/analysisConfig.js @@ -0,0 +1,77 @@ +import { menuItems } from './questionMenuConfig' + +export const noDataConfig = { + title: '暂无数据', + desc: '您的问卷当前还没有数据,快去回收问卷吧!', + img: '/imgs/icons/analysis-empty.webp' +} + +export const separateItemListHead = [ + { + title: '选项', + field: 'text' + }, + { + title: '数量', + field: 'count' + }, + { + title: '占比', + field: 'percent' + } +] + +// 图表名称需要和./chartConfig.js中保持一致 +export const questionChartsConfig = { + [menuItems['checkbox']['type']]: ['bar'], + [menuItems['radio-nps']['type']]: ['gauge', 'pie', 'bar'], + default: ['pie', 'bar'] +} + +export const analysisTypeMap = { + dataTable: 'dataTable', + separateStatistics: 'separateStatistics' +} + +export const analysisType = [ + { + value: analysisTypeMap.dataTable, + label: '数据列表', + icon: 'icon-shujuliebiao' + }, + { + value: analysisTypeMap.separateStatistics, + label: '分题统计', + icon: 'icon-fentitongji' + } +] + +export const summaryType = { + between: 'between' +} + +export const summaryItemConfig = { + 'radio-nps': [ + { + text: '推荐者', + field: 'id', + type: summaryType.between, + max: 10, + min: 9 + }, + { + text: '中立者', + field: 'id', + type: summaryType.between, + max: 8, + min: 7 + }, + { + text: '贬损者', + field: 'id', + type: summaryType.between, + max: 6, + min: 0 + } + ] +} diff --git a/web/src/management/config/chartConfig/bar.js b/web/src/management/config/chartConfig/bar.js new file mode 100644 index 00000000..32b6bb22 --- /dev/null +++ b/web/src/management/config/chartConfig/bar.js @@ -0,0 +1,57 @@ +/** + * @Description: 柱状图配置 + * @CreateDate: 2024-04-30 + */ +export default (data) => { + const xAxisData = data.map((item) => item.name) + return { + color: ['#55A8FD'], + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + }, + formatter: '{a}
{b}: {c}' + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true + }, + xAxis: [ + { + type: 'category', + data: xAxisData, + axisTick: { + alignWithLabel: true + }, + axisLabel: { + interval: 0, + formatter(value) { + return value + } + } + } + ], + yAxis: [ + { + type: 'value', + splitLine: { + lineStyle: { + type: 'dashed' + } + } + } + ], + series: [ + { + showAllSymbol: true, + name: '提交人数', + type: 'bar', + barMaxWidth: 50, + data + } + ] + } +} diff --git a/web/src/management/config/chartConfig/gauge.js b/web/src/management/config/chartConfig/gauge.js new file mode 100644 index 00000000..c3c2678f --- /dev/null +++ b/web/src/management/config/chartConfig/gauge.js @@ -0,0 +1,146 @@ +/** + * @Description: gauge(仪表盘) + * @CreateDate: 2024-04-30 + */ +export default (data) => { + return { + series: [ + { + type: 'gauge', + startAngle: 180, + endAngle: 0, + min: -100, + max: 100, + radius: '130%', + center: ['50%', '80%'], + splitNumber: 4, + z: 2, + axisLabel: { + show: false, + distance: 0, + color: '#AAB1C0', + fontSize: 12, + fontFamily: 'DaQi-Font' + }, + splitLine: { + show: false + }, + axisTick: { + show: false + }, + axisLine: { + lineStyle: { + width: 40, + color: [[1, '#e3efff']] + } + } + }, + { + type: 'gauge', + startAngle: 174, + endAngle: 5, + min: -100, + max: 100, + radius: '130%', + splitNumber: 4, + center: ['50%', '80%'], + z: 3, + axisLabel: { + distance: -5, + color: '#666', + rotate: 'tangential', + fontSize: 12, + fontFamily: 'DaQi-Font' + }, + splitLine: { + show: false + }, + axisTick: { + show: false + }, + axisLine: { + lineStyle: { + width: 40, + color: [[1, '#e3efff']] + } + } + }, + { + type: 'gauge', + startAngle: 178, + endAngle: 0, + min: -100, + max: 100, + radius: '109%', + z: 4, + center: ['50%', '80%'], + splitNumber: 4, + itemStyle: { + color: '#58D9F9' + }, + progress: { + show: true, + roundCap: true, + width: 15, + itemStyle: { + color: '#55A8FD', + shadowBlur: 10, + shadowColor: '#55A8FD' + } + }, + pointer: { + icon: 'triangle', + length: '10%', + width: 8, + offsetCenter: [0, '-80%'], + itemStyle: { + color: '#55A8FD' + } + }, + axisLine: { + lineStyle: { + width: 15, + color: [[1, '#d3e5fe']] + } + }, + axisTick: { + show: false + }, + splitLine: { + show: false + }, + axisLabel: { + show: false + }, + title: { + offsetCenter: [0, '-15%'], + fontSize: 18, + color: '#666' + }, + detail: { + fontSize: 46, + lineHeight: 40, + height: 40, + offsetCenter: [0, '-45%'], + valueAnimation: true, + color: '#55A8FD', + formatter: function (value) { + if (value) { + return value + '%' + } else if (value === 0) { + return value + } else { + return '--' + } + } + }, + data: [ + { + value: data, + name: 'NPS' + } + ] + } + ] + } +} diff --git a/web/src/management/config/chartConfig/index.js b/web/src/management/config/chartConfig/index.js new file mode 100644 index 00000000..6529b379 --- /dev/null +++ b/web/src/management/config/chartConfig/index.js @@ -0,0 +1,9 @@ +import pie from './pie' +import bar from './bar' +import gauge from './gauge' + +export const getOption = { + pie, + bar, + gauge +} diff --git a/web/src/management/config/chartConfig/pie.js b/web/src/management/config/chartConfig/pie.js new file mode 100644 index 00000000..fe5aa5b2 --- /dev/null +++ b/web/src/management/config/chartConfig/pie.js @@ -0,0 +1,57 @@ +const color = [ + '#55A8FD', + '#36CBCB', + '#FAD337', + '#A6D6FF', + '#A177DC', + '#F46C73', + '#FFBA62', + '#ACE474', + '#BEECD6', + '#AFD2FF' +] +/* + * @Description: 饼图配置 + * @CreateDate: 2024-04-30 + */ +export default (data) => { + return { + color, + tooltip: { + trigger: 'item', + formatter: '{a}
{b}: {c} ({d}%)' + }, + legend: { + orient: 'vertical', + right: 12, + top: 12, + tooltip: { + show: true + }, + formatter(name) { + return name.length > 17 ? name.substr(0, 17) + '...' : name + } + }, + series: [ + { + name: '提交人数', + type: 'pie', + radius: ['50%', '80%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 10, + borderColor: '#fff', + borderWidth: 2 + }, + label: { + show: true, + formatter({ data }) { + const name = data?.name || '' + return name.length > 17 ? name.substr(0, 17) + '...' : name + } + }, + data + } + ] + } +} diff --git a/web/src/management/pages/list/config/index.js b/web/src/management/config/listConfig.js similarity index 100% rename from web/src/management/pages/list/config/index.js rename to web/src/management/config/listConfig.js diff --git a/web/src/management/config/questionMenuConfig.js b/web/src/management/config/questionMenuConfig.js index 2eaa27f0..66784bc5 100644 --- a/web/src/management/config/questionMenuConfig.js +++ b/web/src/management/config/questionMenuConfig.js @@ -1,4 +1,4 @@ -const menuItems = { +export const menuItems = { text: { type: 'text', snapshot: '/imgs/question-type-snapshot/iL84te6xxU1657702189333.webp', diff --git a/web/src/management/hooks/useCharts.js b/web/src/management/hooks/useCharts.js new file mode 100644 index 00000000..e15583f7 --- /dev/null +++ b/web/src/management/hooks/useCharts.js @@ -0,0 +1,25 @@ +import * as echarts from 'echarts' +import { getOption } from '@/management/config/chartConfig' + +/** + * 绘制图表 + * @param {Object} el + * @param {String} type + * @param {Array} data + */ +export default (el, type, data) => { + const chart = echarts.init(el) + const option = getOption[type](data) + + chart.setOption(option, true) + + const resize = () => { + chart.resize() + } + + const changeType = (type, data) => { + chart.setOption(getOption[type](data), true) + } + + return { chart, resize, changeType } +} diff --git a/web/src/management/hooks/useResizeObserver.js b/web/src/management/hooks/useResizeObserver.js new file mode 100644 index 00000000..486962f2 --- /dev/null +++ b/web/src/management/hooks/useResizeObserver.js @@ -0,0 +1,20 @@ +// 引入防抖函数 +import _debounce from 'lodash/debounce' +/** + * @description: 监听元素尺寸变化 + * @param {*} el 元素dom + * @param {*} cb resize变化时执行的方法 + * @param {*} wait 防抖间隔 + * @return {*} + */ +export default (el, cb, wait = 200) => { + const resizeObserver = new ResizeObserver(_debounce(cb, wait)) + + resizeObserver.observe(el) + + const destroy = () => { + resizeObserver.disconnect(el) + } + + return { destroy, resizeObserver } +} diff --git a/web/src/management/hooks/useStatisticsItemChart.js b/web/src/management/hooks/useStatisticsItemChart.js new file mode 100644 index 00000000..cc7b18cf --- /dev/null +++ b/web/src/management/hooks/useStatisticsItemChart.js @@ -0,0 +1,77 @@ +import { ref, watchEffect } from 'vue' +import { cleanRichText } from '@/common/xss' +import { questionChartsConfig } from '../config/analysisConfig' + +// 饼图数据处理 +const pie = (data) => { + const aggregation = data?.aggregation + return ( + aggregation?.map?.((item) => { + const { id, count, text } = item + return { + id, + value: count, + name: cleanRichText(text) + } + }) || [] + ) +} +// 柱状图数据处理 +const bar = (data) => { + const aggregation = data?.aggregation + return ( + aggregation?.map?.((item) => { + const { id, count, text } = item + return { + id, + value: count, + name: cleanRichText(text) + } + }) || [] + ) +} +// 仪表盘数据处理 +const gauge = (data) => { + return parseFloat(data?.summary?.nps) || 0 +} + +const dataFormateConfig = { + pie, + bar, + gauge +} + +/** + * @description: 分题统计图表hook + * @param {*} chartType + * @param {*} data + * @return {*} chartRef 图表实例 chartTypeList 图表类型列表 chartType 图表类型 chartData 图表数据 + */ +export default ({ questionType, data }) => { + const chartRef = ref(null) + const chartTypeList = ref([]) + const chartType = ref('') + const chartData = ref({}) + + watchEffect(() => { + if (questionType.value) { + // 根据题型获取图表类型列表 + chartTypeList.value = questionChartsConfig[questionType.value] || questionChartsConfig.default + if (!chartType.value) { + // 默认选中第一项 + chartType.value = chartTypeList.value?.[0] + } + if (chartType.value) { + // 根据图表类型获取图表数据 + chartData.value = dataFormateConfig[chartType.value](data) + } + } + }) + + return { + chartRef, + chartTypeList, + chartType, + chartData + } +} diff --git a/web/src/management/pages/analysis/AnalysisPage.vue b/web/src/management/pages/analysis/AnalysisPage.vue index 0577b8c3..46eb24c2 100644 --- a/web/src/management/pages/analysis/AnalysisPage.vue +++ b/web/src/management/pages/analysis/AnalysisPage.vue @@ -1,139 +1,28 @@ - diff --git a/web/src/management/pages/analysis/components/DataTable.vue b/web/src/management/pages/analysis/components/DataTable.vue index 4ad07baf..5c0e047d 100644 --- a/web/src/management/pages/analysis/components/DataTable.vue +++ b/web/src/management/pages/analysis/components/DataTable.vue @@ -17,22 +17,23 @@ minWidth="200" >