Compare commits

...

103 Commits

Author SHA1 Message Date
dayou
fad2f39cf3
Fix/pinia v2 (#453)
* fix: 锁版本

* fix: 锁版本
2024-11-29 18:19:10 +08:00
dayou
f01465aa6c fix: 解决冲突 2024-10-31 21:40:32 +08:00
Liang-Yaxin
12f19559a9
[Feature]:我的空间新增分组管理 (#439)
* fix: 问卷列表更多按钮图标优化

* feat: 我的空间新增分组管理

* feat: 我的空间新增分组管理

* feat: 我的空间新增分组管理

* feat: 我的空间新增分组管理

* fix: 修改单元测试

* fix: 修改单元测试

* fix: 修改单元测试

* fix: 修改单元测试

* fix: 修改我的空间验收问题

* fix; 修改我的空间验收问题

* fix: 修改二次验收相关问题

---------

Co-authored-by: dayou <853094838@qq.com>
2024-10-31 21:39:08 +08:00
Jiangchunfu
20f01768cf
空间管理样式调整 (#441)
* feat: 空间编辑保存名字同步

* feat: 团队空间空数据隐藏分页器

* feat: 问卷列表添加协作者输入为空时不发接口请求

* feat: 管理团队文字对齐向左

* feat: 团队空间编辑保存名字同步

* feat: 隐藏校验框border

* feat: 问卷列表添加协作者为空时不触发请求

* feat: 隐藏协作管理搜索框校验边框样式

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-10-31 18:00:03 +08:00
Liang-Yaxin
f6202d4f91
feat: 新增题目优化 (#443) 2024-10-31 17:58:38 +08:00
Liang-Yaxin
c93515d2bd
feat: 编辑态优化 (#444) 2024-10-31 17:58:17 +08:00
dayou
6c9ac33ea5
fix: 修复c端页面报错 (#448) 2024-10-30 17:56:30 +08:00
dayou
dffc70c005
fix: 修复依赖版本 (#445)
* fix: 修复依赖版本

* fix: nanoid version

* fix: sass锁定1.79.6的版本

* nanoid reset

* fix: modern-compiler
2024-10-28 21:20:56 +08:00
luch1994
d4bdedb325 feat: 升级docker的版本 2024-10-08 21:28:07 +08:00
Donald Yang
3d4171a7f7
feat:自动保存优化 (#435) 2024-10-08 20:47:33 +08:00
luch
26d92b35ea
feat: 把report迁移到src下 (#440) 2024-10-08 16:26:33 +08:00
sudoooooo
6056a0afde feat: 优化readme 2024-10-08 15:45:08 +08:00
luch
ea8e901d2b
feat: 修改补充单测 (#437) 2024-09-30 18:23:28 +08:00
luch
351f18f255
[Feature] 新增暂停功能以及多表字段升级 (#434)
* feat:新增暂停功能  (#416)

* feat:新增暂停功能

* feat: 修改状态相关功能 (#433)

Co-authored-by: luchunhui <luchunhui@didiglobal.com>

* fix: 修改刷数据逻辑错误和环境变量问题

* fix: 解决lint问题

---------

Co-authored-by: chaorenluo <1243357953@qq.com>
Co-authored-by: luchunhui <luchunhui@didiglobal.com>
2024-09-27 19:52:01 +08:00
sudoooooo
054095e499 feat: 优化内容 2024-09-27 18:24:54 +08:00
Jiangchunfu
81df0eae05
feat: 结果页新增跳转设置 (#432)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-09-27 18:14:32 +08:00
sudoooooo
0a27554bb6 feat: 升级README 2024-09-27 17:21:50 +08:00
sudoooooo
5dfd3d941f feat: Create CODE_OF_CONDUCT.md 2024-09-27 15:16:55 +08:00
sudoooooo
60f96386a9 fix: 修复文档链接 2024-09-24 20:55:41 +08:00
sudoooooo
7f832ce885 fix: 优化配置内容 2024-09-24 15:48:39 +08:00
Jiangchunfu
47ea148866
feat: 皮肤设置2.0 (#426)
* fix: 修复windows上传文件路径反斜杠问题

* feat: 补全服务端skinConf定义

* feat: web端皮肤背景设置2.0

* feat: 删除console.log

* feat: 皮肤设置内容结果增加背景设置以及应用皮肤设置方法抽离

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-09-24 14:09:37 +08:00
sudoooooo
129a803e9a fix: 优化类型校验 2024-09-24 12:13:44 +08:00
sudoooooo
bc0597efe0 fix: 修改api 2024-09-23 15:20:13 +08:00
sudoooooo
365bea25e1 fix: 修复投票题问题 2024-09-23 14:25:09 +08:00
dayou
3d5f04b4a8
fix: 修复最少最多选择 (#412)
* fix: 修复最少最多选择

* fix: 优化获取协作下拉框在协作弹窗出现时调用

* fix: 回退最多最少选择

* perl: 代码优化以及fix lint

---------

Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-09-23 12:33:30 +08:00
Jiangchunfu
f3ebc11b3f
fix: 修复做题页面提交reload页面 (#419)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-09-23 12:29:50 +08:00
sudoooooo
d5669352ed fix: 优化schema初始化管理 2024-09-23 11:47:11 +08:00
sudoooooo
0b686ab4ac fix: 优化代码内容 2024-09-22 12:38:42 +08:00
luch
54a86e1501
feat: 修改导出和登录状态检测代码CR问题 (#431) 2024-09-22 01:42:27 +08:00
sudoooooo
cdb8b6532a fix: 修复写法问题、C端组件问题 2024-09-22 01:41:32 +08:00
dayou
56c37fce3c
fix: 断点续答and编辑检测代码cr优化 (#428)
* fix: 优化代码以及去掉无用字段

* fix: 断点续答验收问题优化

* fix: 优化字段

* fix: lint

* fix: 编辑检测相关问题优化

* fix: lint

* fix: 第二批cr优化

* fix: 去掉无用代码

* fix: 调整session守卫位置

* fix: 文件大小写
2024-09-20 13:26:59 +08:00
sudoooooo
43b20b1be6 fix: 移除有问题的功能 2024-09-14 13:50:51 +08:00
dayou
b749cfa6f6
【Feature】:北大实践课作业 (#424)
* 【北大开源实践】增加数据导出功能 (#294)

* feat:添加了一个文件数据导出的功能和相应前端页面

* fix lint

* fix conflict

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: components.d.ts文件ignore

* feat: Update README_EN.md

* feat: Update README.md

* feat:新增预览功能 (#257)

* feat:问卷预览功能
* feat:修复样式问题

* fix: 优化预览展示

* refactor: 重构vue3组合式API写法 (#265)

* feat: 抽离题型枚举 (#272)

* feat: 抽离题型枚举

* fix: 投放的链接加时间戳去掉ifream缓存

* feat: serve端的node engines

* feat: 权限接口请求优化以及修复其他问题 (#290)

* feat: c端路由改造 (#296)

* 【北大开源实践】增加数据导出功能 (#294)

* feat:添加了一个文件数据导出的功能和相应前端页面

* fix lint

* fix conflict

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: 删除components.d.ts文件

* 【北大开源实践】- 问卷断点续答 - 前端 (#282)

* feat:增加断点续答功能

* feat:增加断点续答功能

* fix: 同步代码并且解决冲突

---------

Co-authored-by: dayou <853094838@qq.com>

* fix: 删除components.d.ts文件最终

* 【北大开源实践】-选项限制 (#284)

* format: 代码格式化 (#160)

* feat: 选项限制

* fix: 同步代码并解决冲突

* fix conflict

* fix conflict

* fix lint

* fix server lint

---------

Co-authored-by: dayou <853094838@qq.com>
Co-authored-by: XiaoYuan <2521510174@qq.com>

* feat: 登录失效检测 & 协作冲突检测 (#287)

Co-authored-by: Liuxinyi <liuxy0406@163.com>
Co-authored-by: dayou <853094838@qq.com>

* fix: peking分支同步develop并解决冲突

* fix: 修正颜色不统一 (#338)

* fix: 修正颜色不统一

* fix: 删除server下的lock文件

* 编辑冲突检测 (#351)

* perl: 选项配额优化

* fix: pinia改写

* feat: 完善北大课程相关的内容

* fix: 修复断点续答以及样式问题 (#420)

* feat: 修改readme

* [Feature]: 密码复杂度检测 (#407)

* feat: 密码复杂度检测

* chore: 改为服务端校验

* feat: 优化展示

* fix:修复编辑页在不同element版本下表现不一致问题 (#406)

* fix: 通过声明element最低版本来确定tab样式表现

* fix lint

* feat(选项设置扩展):选择类题型增加选项排列配置 (#403)

* build: add optimizeDeps packages

* feat(选项设置扩展):选择类题型增加选项排列配置

* feat(选项设置扩展): 验收问题修复

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>

* fix: 删除多余内容

* feat: 优化登录窗口

* fix: 修复断点续答以及样式问题

fix: 修复选项引用验收bug

fix: 修复断点续答问题

fix: 修复断点续答

fix: ignore

fix: 修复投票题默认值

fix: 优化断点续答逻辑

fix: 选中图标适应高度

fix: 回退最大最小选择

fix: 修复断点续答

fix: 修复elswitch不更新问题

fix: 修复访问密码更新不生效问题

fix: 修复样式

fix: 修复多选题最大最小限制

fix: 优化断点续答问题

修复多选题命中最多选择后无法取消问题

fix: 修复服务端的富文本解析

fix:  lint

fix: min error

fix: 修复最少最多选择

fix: 修复投票问卷的最少最多选择

fix: 兼容断点续答情况下选项配额为0的情况

fix: 兼容断点续答情况下选项配额为0的情况

fix: 兼容单选题的断点续答下的选项配额

fix: 修复添加选项问题

fix: 前端提示服务的配额已满

fix: 更新填写的过程中配额减少情况

---------

Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
Co-authored-by: Stahsf <30379566+50431040@users.noreply.github.com>
Co-authored-by: Jiangchunfu <mrj_kevin@163.com>
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>

* feat: 修改验收问题 (#421)

* fix lint

---------

Co-authored-by: Oseast <162945153+Oseast@users.noreply.github.com>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
Co-authored-by: chaorenluo <1243357953@qq.com>
Co-authored-by: Realabiha <48506355+Realabiha@users.noreply.github.com>
Co-authored-by: shiyiting763 <70299297+shiyiting763@users.noreply.github.com>
Co-authored-by: yiyeah <68832436+yiyeah@users.noreply.github.com>
Co-authored-by: XiaoYuan <2521510174@qq.com>
Co-authored-by: Xinyi Liu <74805961+colmon46@users.noreply.github.com>
Co-authored-by: Liuxinyi <liuxy0406@163.com>
Co-authored-by: nil <wangweiguo2013@icloud.com>
Co-authored-by: 王晓聪 <wang86976110@126.com>
Co-authored-by: taoshuang <taoshuang@didiglobal.com>
Co-authored-by: luch1994 <1097650398@qq.com>
Co-authored-by: Stahsf <30379566+50431040@users.noreply.github.com>
Co-authored-by: Jiangchunfu <mrj_kevin@163.com>
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: luch <32321690+luch1994@users.noreply.github.com>
2024-09-12 22:10:18 +08:00
Jiangchunfu
43001a12c7
fix: 修复富文本编辑器上传图片 (#410)
* fix: 修复题目标题插入图片异常问题

* fix: 修改事件监听顺序,避免编辑图片百分比时重新渲染工具条而找不到点击的dom

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-09-06 15:51:12 +08:00
Stahsf
d3c2180ac8
test: 密码检测用例 (#411)
* feat: 密码复杂度检测

* chore: 改为服务端校验

* test: 修改用例

* test: 添加getPasswordStrength测试用例
2024-09-06 15:37:03 +08:00
sudoooooo
63e16e1694 feat: 优化登录窗口 2024-09-03 17:55:59 +08:00
sudoooooo
c6cc0d22e5 fix: 删除多余内容 2024-09-03 16:47:33 +08:00
Jiangchunfu
949a989dcf
feat(选项设置扩展):选择类题型增加选项排列配置 (#403)
* build: add optimizeDeps packages

* feat(选项设置扩展):选择类题型增加选项排列配置

* feat(选项设置扩展): 验收问题修复

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-09-03 15:54:42 +08:00
dayou
9427e0efe5
fix:修复编辑页在不同element版本下表现不一致问题 (#406)
* fix: 通过声明element最低版本来确定tab样式表现

* fix lint
2024-09-02 17:39:47 +08:00
sudoooooo
70c236c879 feat: 优化展示 2024-09-02 17:36:18 +08:00
Stahsf
3d31245ae5
[Feature]: 密码复杂度检测 (#407)
* feat: 密码复杂度检测

* chore: 改为服务端校验
2024-09-02 16:58:53 +08:00
sudoooooo
98fc21995a feat: 修改readme 2024-08-30 12:01:39 +08:00
sudoooooo
f6e3778a2d feat: 优化换行 2024-08-14 21:32:06 +08:00
dayou
6775a9df5e
fix: 删除分页判断是否存在题目的逻辑关联 (#402) 2024-08-14 21:30:16 +08:00
Liang-Yaxin
3cb843e493
fix: 问卷列表更多按钮图标优化 (#401) 2024-08-14 21:12:43 +08:00
dayou
bc3ce31c9e
feat: 跳转逻辑稳定版 (#399)
* feat: 跳转逻辑 (#388)

* fix: 跳转逻辑优化 (#397)

* fix: 跳转逻辑优化

* fix: processJumpSkip逻辑放在题目组件中
2024-08-14 17:59:51 +08:00
sudoooooo
3e7f0cac90 fix: 修复高级设置迁移后无法交互问题 2024-08-14 17:28:59 +08:00
dayou
f3b8ab278a
fix: 更新iconfont链接 (#398) 2024-08-14 10:10:29 +08:00
sudoooooo
013f9ac811 feat: 高级设置组件迁移 2024-08-13 23:44:19 +08:00
sudoooooo
9e07e8330a feat: 升级iconfont 2024-08-13 11:46:59 +08:00
sudoooooo
8950073141 fix: 新建问卷重置计数 2024-08-12 23:10:21 +08:00
Jiangchunfu
4d580bb789
feat: 整卷增加基础配置:必填、显示类型、显示序号、显示分割线 (#391)
* feat: 增加整卷配置功能

* fix: 限制单题修改配置时只对基础配置进行更新全局基础配置操作

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 21:39:19 +08:00
Jiangchunfu
f73bfb0ab3
feat: C端增加重新填写入口, #182 (#392)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 19:43:55 +08:00
sudoooooo
8109d350e4 feat: 优化readme 2024-08-12 19:42:01 +08:00
sudoooooo
b233023bb3 fix: 连续添加题目频繁触发事件导致页面卡顿 2024-08-12 16:08:57 +08:00
sudoooooo
fd7cc2ea96 feat: action不处理format 2024-08-12 14:09:29 +08:00
Jiangchunfu
724535a735
refactor: 题目删除和逻辑关联优化,#182 (#393)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 14:06:38 +08:00
sudoooooo
b5c7ec3008 fix: 皮肤设置tab问题 2024-08-07 22:09:55 +08:00
sudoooooo
c5698ad631 fix: 修复白名单切换和空间成员名字问题 2024-08-07 22:07:00 +08:00
chaorenluo
6fb337633c
fix:修复C端白名单验证弹框不出现的问题 (#389) 2024-08-07 18:33:23 +08:00
Jiangchunfu
42b8d74ead
refactor: 设置器加载统一,代码优化 #269 (#383)
* feat: 小功能建设(4)

* refactor: 设置器加载统一,代码优化 #269

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-07 14:53:14 +08:00
sudoooooo
d8e76dc2e6 feat: 修改Readme 2024-08-06 23:46:21 +08:00
sudoooooo
4f2cd4ca47 feat: 修改字段 2024-08-06 19:59:35 +08:00
sudoooooo
82c98ec1f5 feat: 优化分页器结构 2024-08-06 19:33:11 +08:00
chaorenluo
fbc654f21b
feat:新增分页功能 (#382)
* feat:新增分页功能

* 修复type-check检查

* fix: server  type-check

* fix:修改服务端测试用例

* fix:修复分页bug
2024-08-06 17:30:12 +08:00
Jiangchunfu
9d89a1ceca fix: 修复题目未聚焦时拖拽按钮失效问题 (#375)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-04 12:41:42 +08:00
Jiangchunfu
517906f77f
feat: 小功能建设(4) (#379) 2024-08-04 12:38:28 +08:00
sudoooooo
681e8fa3ae fix: 修复字段difTime->diffTime 2024-08-04 12:21:08 +08:00
Jiangchunfu
4d5c3eb15d
小功能优化8 (#371)
* refactor: 去除重复元素

* feat: edit 布局优化

* style: 题目大纲滚动时固定tab-header

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-31 17:21:54 +08:00
离谱
9a35de7e36
fix(Navbar):修复了短标题hover空白处会触发指示框的bug (#365)
fix(Navbar):修复了短标题hover空白处会触发指示框的bug
2024-07-30 15:27:49 +08:00
dayou
c4b730c8af
fix: 修复提交设置 (#370) 2024-07-30 14:51:33 +08:00
Jiangchunfu
8ea8869ca7 fix: 修复新增同类型题目设置值不变问题 (#359)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-23 15:43:10 +08:00
dayou
7e5a8ae5c1
fix: 修复设置器不更新问题 (#361) 2024-07-23 15:39:43 +08:00
sudoooooo
cf495b60d1 Merge branch 'feature/pinia' into develop
# Conflicts:
#	web/src/management/components/LeftMenu.vue
#	web/src/management/pages/edit/components/ModuleNavbar.vue
#	web/src/management/pages/edit/modules/contentModule/PublishPanel.vue
#	web/src/management/pages/edit/modules/contentModule/SavePanel.vue
#	web/src/management/pages/edit/modules/resultModule/CatalogPanel.vue
#	web/src/management/pages/edit/modules/settingModule/SettingPanel.vue
#	web/src/management/pages/list/components/BaseList.vue
#	web/src/management/pages/list/components/SpaceList.vue
#	web/src/management/pages/list/components/SpaceModify.vue
#	web/src/management/pages/list/index.vue
#	web/src/management/router/index.ts
#	web/src/management/store/edit/getters.js
#	web/src/management/store/list/index.js
#	web/src/management/utils/index.js
#	web/src/materials/questions/widgets/BinaryChoiceModule/meta.js
#	web/src/materials/questions/widgets/CheckboxModule/meta.js
#	web/src/materials/questions/widgets/RadioModule/meta.js
#	web/src/materials/questions/widgets/VoteModule/meta.js
#	web/src/render/pages/RenderPage.vue
#	web/src/render/store/actions.js
#	web/src/render/store/mutations.js
#	web/src/render/store/state.js
2024-07-23 14:14:52 +08:00
sudoooooo
fb57eaaba7 feat: 优化结构 2024-07-22 20:37:11 +08:00
sudoooooo
b494bd6174 feat: 优化编辑页结构 2024-07-22 20:27:12 +08:00
sudoooooo
310fe0d325 feat: 优化编辑页模块和配置结构 2024-07-22 17:33:59 +08:00
Stahsf
ba418c5cd7
feat: 白名单功能-服务端 (#357) 2024-07-20 14:11:19 +08:00
chaorenluo
9596cd07a1
前端新增白名单功能 (#356) 2024-07-19 22:45:40 +08:00
sudoooooo
2ed5b64b18 feat: 优化图片尺寸用于移动端 2024-07-18 21:07:25 +08:00
nil
492e0055f0
feat: 题目与选项支持图片 (#291)
* feat: 题目与选项支持图片

* fix: 修复使用本地存储时文件访问路径不正确的问题

* fix: 图片编辑表单无法输入

* chore: 添加上传文件夹到gitignore

* fix: 两个#app的问题
2024-07-18 10:21:09 +08:00
sudoooooo
93938702fe feat: 更新readme和docker tag 2024-07-17 23:12:17 +08:00
sudoooooo
e8907ca4fb fix: format报错 2024-07-16 11:33:16 +08:00
Jiangchunfu
2b32850046
feat: 题型硬编码优化 (#343)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-16 11:25:08 +08:00
sudoooooo
da1749fb53 feat: 代码format、升级ts和lint 2024-07-15 22:38:14 +08:00
Ken
5bc5eb8719
feat: 空间列表分页和搜索及数据列表优化 (#344)
1、fix: 修复因滚动条宽度影响皮肤标签的问题
2、feat: 空间列表分页和搜索及数据列表样式优化
3、feat: 对部分代码进行了优化
2024-07-15 18:30:50 +08:00
sudoooooo
3227a799f9 fix: 移除选项默认选中 2024-07-15 18:00:07 +08:00
Jiangchunfu
8740685a4d
feat: 功能18优化 (#342)
题目标题编辑自动focus
2024-07-15 17:50:00 +08:00
sudoooooo
5c3915a74d fix: 空间列表高度 2024-07-15 12:11:57 +08:00
ysansan
b1958ec8ff
问卷编辑页面题型选择tab、团队空间列表按钮优化 (#337)
* feat:问卷编辑页面题型选择tab优化

* feat: 团队空间列表按钮优化
2024-07-15 11:24:51 +08:00
sudoooooo
bc39e9933d fix: 避免element-plus提示sass语法 2024-07-15 11:21:35 +08:00
sudoooooo
36dd5a4f2d feat: 增加csdn和x 2024-07-12 16:49:40 +08:00
sudoooooo
a101878313 feat: 优化协作管理路径 2024-07-10 18:36:54 +08:00
Ken
32e43b8260
feat: 协作者管理优化和皮肤标签样式调整 (#333)
* feat: 皮肤设置内主题分类标签样式调整

* feat: 协作者管理优化
2024-07-10 18:06:39 +08:00
Jiangchunfu
9afb23c08e
feat(小功能建设): 15团队空间问卷列表页优化 (#334) 2024-07-10 16:20:31 +08:00
sudoooooo
eaa1abda82 fix: sass1.77.7规则升级warning 2024-07-10 16:07:29 +08:00
hiStephen
6431cc3210
feat: echarts按需引入 (#332) 2024-07-10 15:38:08 +08:00
sudoooooo
122f584cad fix: 修复预览页logo展示问题 2024-07-09 15:23:02 +08:00
Jiangchunfu
f45cf7982f
小功能优化 (#329)
* feat(换肤设置优化): 边距的颜色优化成背景色一致

* feat: 问卷设置优化
2024-07-09 14:17:44 +08:00
Ken
2f0736fd95
feat: 移动端预览优化 (#326)
* feat: 移动端预览优化

* feat: C端底部logo优化
2024-07-09 12:12:13 +08:00
sudoooooo
6c72344204 fix: 修复nginx启动render页空白问题 2024-07-08 21:06:30 +08:00
sudoooooo
61fd6e09af fix: 优化依赖项 2024-07-01 14:14:07 +08:00
若川
349b4dad8c
fix: 🐛 修复引入 import lodash cloneDeep debounce 错误 改为 lodash-es 了 (#322) 2024-07-01 14:08:57 +08:00
344 changed files with 13539 additions and 3853 deletions

View File

@ -37,6 +37,3 @@ jobs:
- name: Lint
run: cd server && npm run lint
- name: Format
run: cd server && npm run format

View File

@ -40,6 +40,3 @@ jobs:
- name: Lint
run: cd web && npm run lint
- name: Format
run: cd web && npm run format

7
.gitignore vendored
View File

@ -25,3 +25,10 @@ pnpm-debug.log*
*.sw?
.history
components.d.ts
# 默认的上传文件夹
userUpload
exportfile
yarn.lock

133
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,133 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[xiaojusurvey@gmail.com](mailto:xiaojusurvey@gmail.com).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

22
DATA_COLLECTION.md Normal file
View File

@ -0,0 +1,22 @@
# Important Disclosure reXIAOJUSURVEY Data Collection
XIAOJUSURVEY is open-source software developed and maintained by XIAOJUSURVEY Team and available at https://github.com/didi/xiaoju-survey.
We hereby state the purpose and reason for collecting data.
## Purpose of data collection
Data collected is used to help improve XIAOJUSURVEY for all users. It is important that our team understands the usage patterns as soon as possible, so we can best decide how to design future features and prioritize current work.
## Types of data collected
XIAOJUSURVEY just collects data about version's information. The data collected is subsequently reported to the XIAOJUSURVEY's backend services.
All data collected will be used exclusively by the XIAOJUSURVEY team for analytical purposes only. The data will be neither accessible nor sold to any third party.
## Sensitive data
XIAOJUSURVEY will never collect and/or report sensitive information, such as private keys, API keys, or passwords.
## How do I opt-in to or opt-out of data sharing?
See [docs](https://xiaojusurvey.didi.cn/docs/next/community/%E6%95%B0%E6%8D%AE%E4%B8%8A%E6%8A%A5%E5%A3%B0%E6%98%8E) for information on configuring this functionality.

166
README.md
View File

@ -29,35 +29,53 @@
<br />
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的**问卷系统基座**,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的调研系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
&ensp;&ensp;内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
&ensp;&ensp;开源项目以打造**调研基座**为核心,围绕**平台能力**、**工程架构**、**研发体系**进行建设,大家可以「快速」打造「专属」问卷系统:[快速了解生态发展理念](https://xiaojusurvey.didi.cn/docs/next/community/%E7%94%9F%E6%80%81%E5%BB%BA%E8%AE%BE)
# 功能特性
# 功能简介
**🌈 易用**
- 问卷管理:创、编、投、收、数据分析
- 多类型数据采集,轻松创建调研表单:文本输入、数据选择、评分、投票、文件上传等。
- 多样化题型:单行输入框、多行输入框、单项选择、多项选择、判断题、评分、投票、...
- 智能逻辑编排,设计多规则动态表单:显示逻辑、跳转逻辑、选项引用、题目引用等。
- 用户管理:登录、注册、权限管理
- 精细权限管理,支持高效团队协同:空间管理、多角色权限管理等。
- 数据安全:传输加密、脱敏等
- 数据在线分析和导出,洞察调研结果:数据导出、回收数据管理、分题统计、交叉分析等。
> 更全的建设请查阅 [官方 Feature](https://github.com/didi/xiaoju-survey/issues/45)
**🎨 好看**
- 主题自由定制适配您的品牌自定义颜色、背景、图片、Logo、结果页规则等。
- 无缝嵌入各终端,满足不同场景需求:多端嵌入式小问卷 SDK。
**🚀 安全、可扩展**
- 安全能力可扩展,提供安全相关建设的经验指导:传输加密、敏感词库、发布审查等。
- 自定义 Hook 配置,轻松集成多方系统与各类工具:数据推送集成、消息推送集成等。
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/dd427471-368d-49d9-bc44-13c34d84e3be" width="700" />
_**(个人和企业用户均可快速构建特定领域的调研类解决方案。)**_
1、 **全部功能**请查看 [功能介绍](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E5%8A%9F%E8%83%BD%E4%BB%8B%E7%BB%8D/%E5%9F%BA%E7%A1%80%E6%B5%81%E7%A8%8B)。
2、**企业**和**个人**均可快速构建特定领域的调研类解决方案。
# 技术
Web 端Vue3 + ElementPlusC 端多端渲染(规划中)
**1、Web 端Vue3 + ElementPlus**
Server 端Nestjs + MongoDBJava在建[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)
&ensp;&ensp;C 端多端渲染ReactNative SDK 建设中
智能化基座:(规划中)
**2、Server 端NestJS + MongoDB**
&ensp;&ensp;Java 版:建设中,[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)
**3、能力增强**
&ensp;&ensp;在线平台:建设中、智能化问卷:规划中
# 项目优势
@ -108,103 +126,25 @@ Server 端Nestjs + MongoDBJava在建[欢迎加入共建](https://git
前后端分离,提供 Docker 化方案,提供了完善的部署指导手册。
# 快速启动
# 快速使用
Node 版本 >= 18.x
[查看环境准备指导](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
_在线平台建设中_
复制工程
# 本地开发
```shell
git clone git@github.com:didi/xiaoju-survey.git
```
请查看 [本地安装手册](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B) 来启动项目。
## 服务端启动
# 快速部署
### 方案一、快速启动,无需安装数据库
### 服务部署
> _便于快速预览工程对于正式项目需要使用方案二。_
请查看 [快速部署指导](https://xiaojusurvey.didi.cn/docs/next/document/%E5%B7%A5%E7%A8%8B%E9%83%A8%E7%BD%B2/Docker%E9%83%A8%E7%BD%B2) 。
#### 1、安装依赖
### 一键部署
```shell
cd server
npm install
```
_手册编写中_
#### 2、启动
```shell
npm run local
```
> 服务运行依赖 [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server)
>
> 1、数据保存在内存中重启服务会更新数据。<br />2、启动内存服务器新实例时如果找不到 MongoDB 二进制文件会自动下载,因此首次可能需要一些时间。
### 方案二、(生产推荐)
#### 1、启动数据库
> 项目使用 MongoDB[MongoDB 安装指导](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85)
- 配置数据库,查看[MongoDB 配置](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93)
- 启动本地数据库,查看[MongoDB 启动](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E4%BA%94%E5%90%AF%E5%8A%A8)
#### 2、安装依赖
```shell
cd server
npm install
```
#### 3、启动
```shell
npm run dev
```
## 前端启动
### 安装依赖
```shell
cd web
npm install
```
### 启动
```shell
npm run serve
```
## 访问
### 问卷管理端
[http://localhost:8080/management](http://localhost:8080)
### 问卷投放端
创建并发布问卷。
[http://localhost:8080/render/:surveyPath](http://localhost:8080/render/:surveyPath)
<br /><br />
## 微信交流群(推荐)
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。任何问题和合作可以联系小助手:
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
## QQ 交流群
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入:
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)
<br />
## Star
@ -212,24 +152,26 @@ npm run serve
[![Star History Chart](https://api.star-history.com/svg?repos=didi/xiaoju-survey&type=Date)](https://star-history.com/#didi/xiaoju-survey&Date)
## 记录
## 交流群
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
_任何问题和合作可以联系小助手。_
## 案例
如果你使用了该项目,请记录反馈:[我在使用](https://github.com/didi/xiaoju-survey/issues/64),你的支持是我们最大的动力。
## Future Tasks
[欢迎了解项目发展和共建](https://github.com/didi/xiaoju-survey/issues/85),你的支持是我们最大的动力。
## 贡献
如果你想成为贡献者或者扩展技术栈,请查看:[贡献者指南](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),你的加入使我们最大的荣幸。
## Feature
关注每周推出的建设:[官方 Feature](https://github.com/didi/xiaoju-survey/issues/45)
## CHANGELOG
关注重大变更:[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)
## 文章分享
1、[掘金](https://juejin.cn/user/3705833332160473/posts)、2、[InfoQ](https://www.infoq.cn/profile/7E08AC616A07B2/publish)
[欢迎投稿](https://xiaojusurvey.didi.cn/docs/next/article/%E7%AE%80%E4%BB%8B)
关注项目重大变更:[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)。

View File

@ -29,33 +29,47 @@
<br />
&ensp;&ensp;XIAOJUSURVEY is a lightweight, secure questionnaire system foundation that provides one-stop product-level solutions for individuals and enterprises, quickly meeting various online survey scenarios.
&ensp;&ensp;XIAOJUSURVEY is an enterprises form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.
&ensp;&ensp;The internal system has accumulated over 40 question types and more than 100 selected templates, suitable for market research, customer satisfaction surveys, online exams, voting, reporting, evaluations, and many other scenarios. In terms of data capabilities, it has been honed through hundreds of millions of iterations, resulting in the ability to provide online reports with per-question statistics, cross-analysis, and multi-channel analysis, quickly meeting professional analysis needs.
&ensp;&ensp;The open-source project focuses on building a survey foundation, constructing around platform capabilities, engineering structure, and development systems, allowing everyone to 「quickly」 create their own 「exclusive」 questionnaire system: [quickly understanding the ecological development philosophy](https://xiaojusurvey.didi.cn/docs/next/community/%E7%94%9F%E6%80%81%E5%BB%BA%E8%AE%BE).
# Features
# Function Overview
**🌈 Easy to use**
- Questionnaire Management: Create, edit, distribute, collect, data analysis.
- Multi-type data collection, easy to create forms: text input, data selection, scoring, voting, file upload, etc.
- Diverse Question Types: Single-line input, multi-line input, single choice, multiple choice, true/false, rating, voting, etc.
- Smart logic arrangement, design multi-rule dynamic forms: display logic, jump logic, option reference, title reference, etc.
- User Management: Login, registration, permissions management.
- Multiple permission management, support efficient team collaboration: space management, multi-role permission management, etc.
- Data Security: Encrypted transmission, data masking, etc.
- Online data analysis and export, insight into survey results: data export, recycled data management, sub-topic statistics, cross-analysis, etc.
> For more comprehensive features, please refer to the official Feature documentation.
**🎨 Good-looking**
- Free customization of themes to adapt to your brand: custom colors, backgrounds, pictures, logos, result page rules, etc.
- Seamlessly embedded in various terminals to meet the needs of different scenarios: multi-terminal embedded small questionnaire SDK.
**🚀 Secure and scalable**
- Scalable security capabilities, providing experience guidance for security-related construction: encrypted transmission, data masking, etc.
- Customized Hook configuration, easy integration of multiple systems and various tools: data push, message push, etc.
<img src="https://github.com/didi/xiaoju-survey/assets/16012672/508ce30f-0ae8-4f5f-84a7-e96de8238a7f" width="700" />
_**(Both individual and enterprise users can quickly build survey solutions specific to their fields.)**_
1. For more comprehensive features, please refer to the [documentation](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E5%8A%9F%E8%83%BD%E4%BB%8B%E7%BB%8D/%E5%9F%BA%E7%A1%80%E6%B5%81%E7%A8%8B).
2. Both individual and enterprise users can quickly build survey solutions specific to their fields.
# Technology
Web: Vue3 + ElementPlus; Multi-end rendering for C-end (planning).
Server: Nestjs + MongoDB; Java ([under construction](https://github.com/didi/xiaoju-survey/issues/306)).
Server: NestJS + MongoDB; Java ([under construction](https://github.com/didi/xiaoju-survey/issues/306)).
Online Platform: (under construction).
Intelligent Foundation: (planning).
@ -145,12 +159,11 @@ npm run local
### Option 2: (Recommended for Production)
#### 1.Start Database
#### 1.Configure Database
> The project uses MongoDB: [MongoDB Installation Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85)
> The project uses MongoDB: [MongoDB Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93)
- Configure the database, check MongoDB configuration.
- Start local database, check MongoDB startup.
Configure the database, check MongoDB configuration.
#### 2.Install Dependencies
@ -194,22 +207,18 @@ Create and publish a questionnaire.
<br /><br />
## Star
Open source is not easy. If this project helps you, please star it ❤️❤️❤️. Your support is our greatest motivation.
[![Star History Chart](https://api.star-history.com/svg?repos=didi/xiaoju-survey&type=Date)](https://star-history.com/#didi/xiaoju-survey&Date)
## WeChat Group
The official group will release the latest project news, construction plans, and community activities. Any questions and cooperation can contact the assistant:
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
## QQ Group
The official group will release the latest project news, construction plans, and community activities. Welcome to join:
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="210" />](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.
## Feedback
If you use this project, please leave feedback:[I'm using](https://github.com/didi/xiaoju-survey/issues/64), Your support is our greatest.
@ -218,16 +227,11 @@ If you use this project, please leave feedback:[I'm using](https://github.com/di
If you want to become a contributor or expand your technical stack, please check: [Contributor Guide](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). Your participation is our greatest honor.
## Feature
## Future Tasks
Pay attention to weekly construction updates: [Official Feature](https://github.com/didi/xiaoju-survey/issues/45)
1. [Official Feature](https://github.com/didi/xiaoju-survey/issues/45)
2. [WIP](https://github.com/didi/xiaoju-survey/labels/WIP)
## CHANGELOG
Follow major changes: [MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)
## Article Sharing
1、[JueJin](https://juejin.cn/user/3705833332160473/posts)、2、[InfoQ](https://www.infoq.cn/profile/7E08AC616A07B2/publish)
[Welcome to contribute.](https://xiaojusurvey.didi.cn/docs/next/article/%E7%AE%80%E4%BB%8B)

View File

@ -15,7 +15,7 @@ services:
- xiaoju-survey
xiaoju-survey:
image: "xiaojusurvey/xiaoju-survey:1.1.2-slim"
image: "xiaojusurvey/xiaoju-survey:1.3.0-slim" # 最新版本https://hub.docker.com/r/xiaojusurvey/xiaoju-survey/tags
container_name: xiaoju-survey
restart: always
ports:

View File

@ -51,6 +51,15 @@ http {
location /api {
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 {
proxy_pass http://127.0.0.1:3000;
}
error_page 500 502 503 504 /500.html;
client_max_body_size 20M;

View File

@ -1,7 +1,13 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017
XIAOJU_SURVEY_MONGO_URL=
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin
XIAOJU_SURVEY_REDIS_HOST=
XIAOJU_SURVEY_REDIS_PORT=
XIAOJU_SURVEY_REDIS_USERNAME=
XIAOJU_SURVEY_REDIS_PASSWORD=
XIAOJU_SURVEY_REDIS_DB=
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey
XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa

View File

@ -0,0 +1,20 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
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_HTTP_DATA_ENCRYPT_TYPE=rsa
XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret
XIAOJU_SURVEY_JWT_EXPIRES_IN=8h
XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log
XIAOJU_SURVEY_REPORT=true

View File

@ -0,0 +1,17 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=admin
XIAOJU_SURVEY_REDIS_HOST=
XIAOJU_SURVEY_REDIS_PORT=
XIAOJU_SURVEY_REDIS_USERNAME=
XIAOJU_SURVEY_REDIS_PASSWORD=
XIAOJU_SURVEY_REDIS_DB=
XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY=dataAesEncryptSecretKey
XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa
XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret
XIAOJU_SURVEY_JWT_EXPIRES_IN=8h
XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log

5
server/.gitignore vendored
View File

@ -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
tmp
exportfile
userUpload

View File

@ -1,7 +1,7 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"name": "xiaoju-survey-server",
"version": "1.3.0",
"description": "XIAOJUSURVEY的server端",
"author": "",
"scripts": {
"build": "nest build",
@ -22,15 +22,17 @@
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/microservices": "^10.4.4",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.1",
"ali-oss": "^6.20.0",
"cheerio": "^1.0.0-rc.12",
"cheerio": "1.0.0-rc.12",
"crypto-js": "^4.2.0",
"dotenv": "^16.3.2",
"fs-extra": "^11.2.0",
"ioredis": "^5.4.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
@ -41,7 +43,9 @@
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-xlsx": "^0.24.0",
"qiniu": "^7.11.1",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"svg-captcha": "^1.4.0",
@ -61,8 +65,8 @@
"@types/node": "^20.3.1",
"@types/node-forge": "^1.3.11",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"cross-env": "^7.0.3",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
@ -70,13 +74,14 @@
"jest": "^29.5.0",
"mongodb-memory-server": "^9.1.4",
"prettier": "^3.0.0",
"redis-memory-server": "^0.11.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.5.3"
},
"jest": {
"moduleFileExtensions": [
@ -90,7 +95,9 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"**/*.(t|j)s",
"!**/*.module.ts",
"!**/upgrade.*.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",

View File

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

View File

@ -14,6 +14,7 @@ import { AuthModule } from './modules/auth/auth.module';
import { MessageModule } from './modules/message/message.module';
import { FileModule } from './modules/file/file.module';
import { WorkspaceModule } from './modules/workspace/workspace.module';
import { UpgradeModule } from './modules/upgrade/upgrade.module';
import { join } from 'path';
@ -28,6 +29,7 @@ import { SurveyHistory } from './models/surveyHistory.entity';
import { ResponseSchema } from './models/responseSchema.entity';
import { Counter } from './models/counter.entity';
import { SurveyResponse } from './models/surveyResponse.entity';
import { SurveyGroup } from './models/surveyGroup.entity';
import { ClientEncrypt } from './models/clientEncrypt.entity';
import { Word } from './models/word.entity';
import { MessagePushingTask } from './models/messagePushingTask.entity';
@ -35,16 +37,21 @@ import { MessagePushingLog } from './models/messagePushingLog.entity';
import { WorkspaceMember } from './models/workspaceMember.entity';
import { Workspace } from './models/workspace.entity';
import { Collaborator } from './models/collaborator.entity';
import { DownloadTask } from './models/downloadTask.entity';
import { Session } from './models/session.entity';
import { LoggerProvider } from './logger/logger.provider';
import { PluginManagerProvider } from './securityPlugin/pluginManager.provider';
import { LogRequestMiddleware } from './middlewares/logRequest.middleware';
import { XiaojuSurveyPluginManager } from './securityPlugin/pluginManager';
import { PluginManager } from './securityPlugin/pluginManager';
import { Logger } from './logger';
@Module({
imports: [
ConfigModule.forRoot({}),
ConfigModule.forRoot({
envFilePath: `.env.${process.env.NODE_ENV}`, // 根据 NODE_ENV 动态加载对应的 .env 文件
isGlobal: true, // 使配置模块在应用的任何地方可用
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
@ -72,6 +79,7 @@ import { Logger } from './logger';
SurveyConf,
SurveyHistory,
SurveyResponse,
SurveyGroup,
Counter,
ResponseSchema,
ClientEncrypt,
@ -81,6 +89,8 @@ import { Logger } from './logger';
Workspace,
WorkspaceMember,
Collaborator,
DownloadTask,
Session,
],
};
},
@ -100,6 +110,7 @@ import { Logger } from './logger';
MessageModule,
FileModule,
WorkspaceModule,
UpgradeModule,
],
controllers: [AppController],
providers: [
@ -114,7 +125,7 @@ import { Logger } from './logger';
export class AppModule {
constructor(
private readonly configService: ConfigService,
private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly pluginManager: PluginManager,
) {}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LogRequestMiddleware).forRoutes('*');

View File

@ -0,0 +1,6 @@
export enum DOWNLOAD_TASK_STATUS {
WAITING = 'waiting', // 排队中
COMPUTING = 'computing', // 计算中
SUCCEED = 'succeed', // 导出成功
FAILED = 'failed', // 导出失败
}

View File

@ -6,18 +6,22 @@ export enum EXCEPTION_CODE {
USER_EXISTS = 2001, // 用户已存在
USER_NOT_EXISTS = 2002, // 用户不存在
USER_PASSWORD_WRONG = 2003, // 用户名或密码错误
PASSWORD_INVALID = 2004, // 密码无效
NO_SURVEY_PERMISSION = 3001, // 没有问卷权限
SURVEY_STATUS_TRANSFORM_ERROR = 3002, // 问卷状态转换报错
SURVEY_TYPE_ERROR = 3003, // 问卷类型错误
SURVEY_NOT_FOUND = 3004, // 问卷不存在
SURVEY_CONTENT_NOT_ALLOW = 3005, // 存在禁用内容
SURVEY_SAVE_CONFLICT = 3006, // 问卷冲突
CAPTCHA_INCORRECT = 4001, // 验证码不正确
WHITELIST_ERROR = 4002, // 白名单校验错误
RESPONSE_SIGN_ERROR = 9001, // 签名不正确
RESPONSE_CURRENT_TIME_NOT_ALLOW = 9002, // 当前时间不允许提交
RESPONSE_OVER_LIMIT = 9003, // 超出限制
RESPONSE_SCHEMA_REMOVED = 9004, // 问卷已删除
RESPONSE_DATA_DECRYPT_ERROR = 9005, // 问卷已删除
RESPONSE_PAUSING = 9006, // 问卷已暂停
UPLOAD_FILE_ERROR = 5001, // 上传文件错误
}

View File

@ -1,11 +1,15 @@
// 状态类型
export enum RECORD_STATUS {
NEW = 'new', // 新建
EDITING = 'editing', // 编辑
PAUSING = 'pausing', // 暂停
NEW = 'new', // 新建 | 未发布
PUBLISHED = 'published', // 发布
REMOVED = 'removed', // 删除
FORCE_REMOVED = 'forceRemoved', // 从回收站删除
EDITING = 'editing', // 编辑
FINISHED = 'finished', // 已结束
REMOVED = 'removed',
}
export const enum RECORD_SUB_STATUS {
DEFAULT = '', // 默认
PAUSING = 'pausing', // 暂停
}
// 历史类型

View File

@ -0,0 +1,37 @@
/**
* @description
*/
export enum QUESTION_TYPE {
/**
*
*/
TEXT = 'text',
/**
*
*/
TEXTAREA = 'textarea',
/**
*
*/
RADIO = 'radio',
/**
*
*/
CHECKBOX = 'checkbox',
/**
*
*/
BINARY_CHOICE = 'binary-choice',
/**
*
*/
RADIO_STAR = 'radio-star',
/**
* nps评分
*/
RADIO_NPS = 'radio-nps',
/**
*
*/
VOTE = 'vote',
}

View File

@ -0,0 +1,4 @@
export enum SESSION_STATUS {
ACTIVATED = 'activated',
DEACTIVATED = 'deactivated',
}

View File

@ -0,0 +1,68 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SessionService } from 'src/modules/survey/services/session.service';
import { SessionGuard } from '../session.guard';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
describe('SessionGuard', () => {
let sessionGuard: SessionGuard;
let reflector: Reflector;
let sessionService: SessionService;
beforeEach(() => {
reflector = new Reflector();
sessionService = {
findOne: jest.fn(),
} as unknown as SessionService;
sessionGuard = new SessionGuard(reflector, sessionService);
});
it('should return true when sessionId exists and sessionService returns sessionInfo', async () => {
const mockSessionId = '12345';
const mockSessionInfo = { id: mockSessionId, name: 'test session' };
const context = {
switchToHttp: jest.fn().mockReturnThis(),
getRequest: jest.fn().mockReturnValue({
sessionId: mockSessionId,
}),
getHandler: jest.fn(),
} as unknown as ExecutionContext;
jest.spyOn(reflector, 'get').mockReturnValue('sessionId');
jest
.spyOn(sessionService, 'findOne')
.mockResolvedValue(mockSessionInfo as any);
const result = await sessionGuard.canActivate(context);
const request = context.switchToHttp().getRequest();
expect(result).toBe(true);
expect(reflector.get).toHaveBeenCalledWith(
'sessionId',
context.getHandler(),
);
expect(sessionService.findOne).toHaveBeenCalledWith(mockSessionId);
expect(request.sessionInfo).toEqual(mockSessionInfo);
});
it('should throw NoPermissionException when sessionId is missing', async () => {
const context = {
switchToHttp: jest.fn().mockReturnThis(),
getRequest: jest.fn().mockReturnValue({}),
getHandler: jest.fn(),
} as unknown as ExecutionContext;
jest.spyOn(reflector, 'get').mockReturnValue('sessionId');
await expect(sessionGuard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
expect(reflector.get).toHaveBeenCalledWith(
'sessionId',
context.getHandler(),
);
});
});

View File

@ -0,0 +1,30 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { get } from 'lodash';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { SessionService } from 'src/modules/survey/services/session.service';
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly sessionService: SessionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const sessionIdKey = this.reflector.get<string>(
'sessionId',
context.getHandler(),
);
const sessionId = get(request, sessionIdKey);
if (!sessionId) {
throw new NoPermissionException('没有权限');
}
const sessionInfo = await this.sessionService.findOne(sessionId);
request.sessionInfo = sessionInfo;
request.surveyId = sessionInfo.surveyId;
return true;
}
}

View File

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

View File

@ -88,24 +88,71 @@ export interface MsgContent {
msg_9004: string;
}
export interface JumpConfig {
type: string;
link: string;
buttonText?: string;
}
export interface SubmitConf {
submitTitle: string;
confirmAgain: ConfirmAgain;
msgContent: MsgContent;
jumpConfig?: JumpConfig;
}
// 白名单类型
export enum WhitelistType {
ALL = 'ALL',
// 空间成员
MEMBER = 'MEMBER',
// 自定义
CUSTOM = 'CUSTOM',
}
// 白名单用户类型
export enum MemberType {
// 手机号
MOBILE = 'MOBILE',
// 邮箱
EMAIL = 'EMAIL',
}
export interface BaseConf {
begTime: string;
beginTime: string;
endTime: string;
answerBegTime: string;
answerEndTime: string;
tLimit: number;
language: string;
// 访问密码开关
passwordSwitch?: boolean;
// 密码
password?: string | null;
// 白名单类型
whitelistType?: WhitelistType;
// 白名单用户类型
memberType?: MemberType;
// 白名单列表
whitelist?: string[];
// 提示语
whitelistTip?: string;
}
export interface SkinConf {
skinColor: string;
inputBgColor: string;
backgroundConf: {
color: string;
type: string;
image: string;
};
contentConf: {
opacity: number;
};
themeConf: {
color: string;
};
}
export interface BottomConf {

View File

@ -1,15 +1,18 @@
import * as log4js from 'log4js';
import moment from 'moment';
import { Request } from 'express';
import { Injectable, Scope, Inject } from '@nestjs/common';
import { CONTEXT, RequestContext } from '@nestjs/microservices';
const log4jsLogger = log4js.getLogger();
@Injectable({ scope: Scope.REQUEST })
export class Logger {
private static inited = false;
constructor() {}
constructor(@Inject(CONTEXT) private readonly ctx: RequestContext) {}
static init(config: { filename: string }) {
if (this.inited) {
if (Logger.inited) {
return;
}
log4js.configure({
@ -30,25 +33,26 @@ export class Logger {
default: { appenders: ['app'], level: 'trace' },
},
});
Logger.inited = true;
}
_log(message, options: { dltag?: string; level: string; req?: Request }) {
_log(message, options: { dltag?: string; level: string }) {
const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
const level = options?.level;
const dltag = options?.dltag ? `${options.dltag}||` : '';
const traceIdStr = options?.req?.['traceId']
? `traceid=${options?.req?.['traceId']}||`
const traceIdStr = this.ctx?.['traceId']
? `traceid=${this.ctx?.['traceId']}||`
: '';
return log4jsLogger[level](
`[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`,
);
}
info(message, options?: { dltag?: string; req?: Request }) {
info(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'info' });
}
error(message, options: { dltag?: string; req?: Request }) {
error(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'error' });
}
}

View File

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

View File

@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import './report';
async function bootstrap() {
const PORT = process.env.PORT || 3000;

View File

@ -1,4 +1,3 @@
// logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { Logger } from '../logger/index'; // 替换为你实际的logger路径
@ -20,7 +19,6 @@ export class LogRequestMiddleware implements NestMiddleware {
`method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`,
{
dltag: 'request_in',
req,
},
);
@ -30,7 +28,6 @@ export class LogRequestMiddleware implements NestMiddleware {
`status=${res.statusCode.toString()}||duration=${duration}ms`,
{
dltag: 'request_out',
req,
},
);
});

View File

@ -1,30 +0,0 @@
import { BaseEntity } from '../base.entity';
import { RECORD_STATUS } from 'src/enums';
describe('BaseEntity', () => {
let baseEntity: BaseEntity;
beforeEach(() => {
baseEntity = new BaseEntity();
});
it('should initialize default info before insert', () => {
const now = Date.now();
baseEntity.initDefaultInfo();
expect(baseEntity.curStatus.status).toBe(RECORD_STATUS.NEW);
expect(baseEntity.curStatus.date).toBeCloseTo(now, -3);
expect(baseEntity.statusList).toHaveLength(1);
expect(baseEntity.statusList[0].status).toBe(RECORD_STATUS.NEW);
expect(baseEntity.statusList[0].date).toBeCloseTo(now, -3);
expect(baseEntity.createDate).toBeCloseTo(now, -3);
expect(baseEntity.updateDate).toBeCloseTo(now, -3);
});
it('should update updateDate before update', () => {
const now = Date.now();
baseEntity.onUpdate();
expect(baseEntity.updateDate).toBeCloseTo(now, -3);
});
});

View File

@ -0,0 +1,57 @@
import { SurveyMeta } from '../surveyMeta.entity';
import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums';
// 模拟日期
const mockDateNow = Date.now();
describe('SurveyMeta Entity', () => {
let surveyMeta: SurveyMeta;
// 在每个测试之前,初始化 SurveyMeta 实例
beforeEach(() => {
surveyMeta = new SurveyMeta();
// 模拟 Date.now() 返回固定的时间
jest.spyOn(Date, 'now').mockReturnValue(mockDateNow);
});
afterEach(() => {
jest.restoreAllMocks(); // 每次测试后还原所有 mock
});
it('should set default curStatus and subStatus on insert when they are not provided', () => {
surveyMeta.initDefaultInfo();
// 验证 curStatus 是否被初始化为默认值
expect(surveyMeta.curStatus).toEqual({
status: RECORD_STATUS.NEW,
date: mockDateNow,
});
// 验证 statusList 是否包含 curStatus
expect(surveyMeta.statusList).toEqual([
{
status: RECORD_STATUS.NEW,
date: mockDateNow,
},
]);
// 验证 subStatus 是否被初始化为默认值
expect(surveyMeta.subStatus).toEqual({
status: RECORD_SUB_STATUS.DEFAULT,
date: mockDateNow,
});
});
it('should initialize statusList if curStatus is provided but statusList is empty', () => {
surveyMeta.curStatus = null;
surveyMeta.initDefaultInfo();
expect(surveyMeta.statusList).toEqual([
{
status: RECORD_STATUS.NEW,
date: expect.any(Number),
},
]);
});
});

View File

@ -1,43 +1,13 @@
import { Column, ObjectIdColumn, BeforeInsert, BeforeUpdate } from 'typeorm';
import { ObjectIdColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
export class BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@CreateDateColumn({ type: 'timestamp', precision: 3 })
createdAt: Date;
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
@UpdateDateColumn({ type: 'timestamp', precision: 3 })
updatedAt: Date;
}

View File

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

View File

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

View File

@ -11,4 +11,16 @@ export class Collaborator extends BaseEntity {
@Column('jsonb')
permissions: Array<string>;
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -0,0 +1,48 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
import { DOWNLOAD_TASK_STATUS } from 'src/enums/downloadTaskStatus';
@Entity({ name: 'downloadTask' })
export class DownloadTask extends BaseEntity {
@Column()
surveyId: string;
@Column()
surveyPath: string;
// 文件路径
@Column()
url: string;
// 文件key
@Column()
fileKey: string;
// 任务创建人
@Column()
creatorId: string;
// 任务创建人
@Column()
creator: string;
// 文件名
@Column()
filename: string;
// 文件大小
@Column()
fileSize: string;
@Column()
params: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
@Column()
status: DOWNLOAD_TASK_STATUS;
}

View File

@ -27,4 +27,16 @@ export class MessagePushingTask extends BaseEntity {
@Column()
ownerId: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -1,6 +1,7 @@
import { Entity, Column } from 'typeorm';
import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums';
@Entity({ name: 'surveyPublish' })
export class ResponseSchema extends BaseEntity {
@ -15,4 +16,19 @@ export class ResponseSchema extends BaseEntity {
@Column()
pageId: string;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
subStatus: {
status: RECORD_SUB_STATUS;
date: number;
};
@Column()
isDeleted: boolean;
}

View File

@ -0,0 +1,22 @@
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
import { BaseEntity } from './base.entity';
import { SESSION_STATUS } from 'src/enums/surveySessionStatus';
@Entity({ name: 'session' })
export class Session extends BaseEntity {
@Index({
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;
@Column()
surveyId: string;
@Column()
userId: string;
@Column()
status: SESSION_STATUS;
}

View File

@ -0,0 +1,11 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyGroup' })
export class SurveyGroup extends BaseEntity {
@Column()
ownerId: string;
@Column()
name: string;
}

View File

@ -19,4 +19,7 @@ export class SurveyHistory extends BaseEntity {
username: string;
_id: string;
};
@Column('string')
sessionId: string;
}

View File

@ -1,5 +1,6 @@
import { Entity, Column } from 'typeorm';
import { Entity, Column, BeforeInsert } from 'typeorm';
import { BaseEntity } from './base.entity';
import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums';
@Entity({ name: 'surveyMeta' })
export class SurveyMeta extends BaseEntity {
@ -18,6 +19,9 @@ export class SurveyMeta extends BaseEntity {
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
owner: string;
@ -32,4 +36,51 @@ export class SurveyMeta extends BaseEntity {
@Column()
workspaceId: string;
@Column()
groupId: string;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
subStatus: {
status: RECORD_SUB_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS | RECORD_SUB_STATUS;
date: number;
}>;
@Column()
operator: string;
@Column()
operatorId: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
if (!this.subStatus) {
const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now };
this.subStatus = subStatus;
}
}
}

View File

@ -14,7 +14,7 @@ export class SurveyResponse extends BaseEntity {
data: Record<string, any>;
@Column()
difTime: number;
diffTime: number;
@Column()
clientTime: number;
@ -27,11 +27,11 @@ export class SurveyResponse extends BaseEntity {
@BeforeInsert()
async onDataInsert() {
return await pluginManager.triggerHook('beforeResponseDataCreate', this);
return await pluginManager.triggerHook('encryptResponseData', this);
}
@AfterLoad()
async onDataLoaded() {
return await pluginManager.triggerHook('afterResponseDataReaded', this);
return await pluginManager.triggerHook('decryptResponseData', this);
}
}

View File

@ -3,12 +3,33 @@ import { BaseEntity } from './base.entity';
@Entity({ name: 'workspace' })
export class Workspace extends BaseEntity {
@Column()
creatorId: string;
@Column()
creator: string;
@Column()
ownerId: string;
@Column()
owner: string;
@Column()
name: string;
@Column()
description: string;
@Column()
operator: string;
@Column()
operatorId: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
}

View File

@ -11,4 +11,16 @@ export class WorkspaceMember extends BaseEntity {
@Column()
role: string;
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -82,6 +82,22 @@ describe('AuthController', () => {
new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT),
);
});
it('should throw HttpException with PASSWORD_INVALID code when password is invalid', async () => {
const mockUserInfo = {
username: 'testUser',
password: '无效的密码abc123',
captchaId: 'testCaptchaId',
captcha: 'testCaptcha',
};
await expect(controller.register(mockUserInfo)).rejects.toThrow(
new HttpException(
'密码只能输入数字、字母、特殊字符',
EXCEPTION_CODE.PASSWORD_INVALID,
),
);
});
});
describe('login', () => {
@ -204,4 +220,29 @@ describe('AuthController', () => {
expect(typeof result.data.img).toBe('string');
});
});
describe('password strength', () => {
it('it should return strong', async () => {
await expect(
controller.getPasswordStrength('abcd&1234'),
).resolves.toEqual({
code: 200,
data: 'Strong',
});
});
it('it should return medium', async () => {
await expect(controller.getPasswordStrength('abc123')).resolves.toEqual({
code: 200,
data: 'Medium',
});
});
it('it should return weak', async () => {
await expect(controller.getPasswordStrength('123456')).resolves.toEqual({
code: 200,
data: 'Weak',
});
});
});
});

View File

@ -5,7 +5,6 @@ import { UserService } from '../services/user.service';
import { User } from 'src/models/user.entity';
import { HttpException } from 'src/exceptions/httpException';
import { hash256 } from 'src/utils/hash256';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
describe('UserService', () => {
@ -145,7 +144,6 @@ describe('UserService', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
username: username,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toEqual(userInfo);
@ -163,7 +161,6 @@ describe('UserService', () => {
expect(findOneSpy).toHaveBeenCalledWith({
where: {
username: username,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toBe(null);
@ -184,7 +181,6 @@ describe('UserService', () => {
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toEqual(userInfo);
@ -202,7 +198,6 @@ describe('UserService', () => {
expect(findOneSpy).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
expect(user).toBe(null);
@ -211,8 +206,8 @@ describe('UserService', () => {
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() },
{ _id: new ObjectId(), username: 'testUser1', createdAt: new Date() },
{ _id: new ObjectId(), username: 'testUser2', createdAt: new Date() },
];
jest
@ -228,11 +223,10 @@ describe('UserService', () => {
expect(userRepository.find).toHaveBeenCalledWith({
where: {
username: new RegExp(username),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
skip: 0,
take: 10,
select: ['_id', 'username', 'createDate'],
select: ['_id', 'username', 'createdAt'],
});
expect(result).toEqual(userList);
});
@ -243,12 +237,12 @@ describe('UserService', () => {
{
_id: new ObjectId(idList[0]),
username: 'testUser1',
createDate: new Date(),
createdAt: new Date(),
},
{
_id: new ObjectId(idList[1]),
username: 'testUser2',
createDate: new Date(),
createdAt: new Date(),
},
];
@ -263,9 +257,8 @@ describe('UserService', () => {
_id: {
$in: idList.map((id) => new ObjectId(id)),
},
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
select: ['_id', 'username', 'createDate'],
select: ['_id', 'username', 'createdAt'],
});
expect(result).toEqual(userList);
});

View File

@ -1,4 +1,12 @@
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import {
Controller,
Post,
Body,
HttpCode,
Get,
Query,
Request,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UserService } from '../services/user.service';
import { CaptchaService } from '../services/captcha.service';
@ -7,6 +15,9 @@ import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { create } from 'svg-captcha';
import { ApiTags } from '@nestjs/swagger';
const passwordReg = /^[a-zA-Z0-9!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+$/;
@ApiTags('auth')
@Controller('/api/auth')
export class AuthController {
@ -28,6 +39,24 @@ export class AuthController {
captcha: string;
},
) {
if (!userInfo.password) {
throw new HttpException('密码无效', EXCEPTION_CODE.PASSWORD_INVALID);
}
if (userInfo.password.length < 6 || userInfo.password.length > 16) {
throw new HttpException(
'密码长度在 6 到 16 个字符',
EXCEPTION_CODE.PASSWORD_INVALID,
);
}
if (!passwordReg.test(userInfo.password)) {
throw new HttpException(
'密码只能输入数字、字母、特殊字符',
EXCEPTION_CODE.PASSWORD_INVALID,
);
}
const isCorrect = await this.captchaService.checkCaptchaIsCorrect({
captcha: userInfo.captcha,
id: userInfo.captchaId,
@ -162,4 +191,59 @@ export class AuthController {
},
};
}
/**
*
*/
@Get('/password/strength')
@HttpCode(200)
async getPasswordStrength(@Query('password') password: string) {
const numberReg = /[0-9]/.test(password);
const letterReg = /[a-zA-Z]/.test(password);
const symbolReg = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password);
// 包含三种、且长度大于8
if (numberReg && letterReg && symbolReg && password.length >= 8) {
return {
code: 200,
data: 'Strong',
};
}
// 满足任意两种
if ([numberReg, letterReg, symbolReg].filter(Boolean).length >= 2) {
return {
code: 200,
data: 'Medium',
};
}
return {
code: 200,
data: 'Weak',
};
}
@Get('/verifyToken')
@HttpCode(200)
async verifyToken(@Request() req) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return {
code: 200,
data: false,
};
}
try {
await this.authService.verifyToken(token);
return {
code: 200,
data: true,
};
} catch (error) {
return {
code: 200,
data: false,
};
}
}
}

View File

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

View File

@ -5,7 +5,6 @@ import { User } from 'src/models/user.entity';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { hash256 } from 'src/utils/hash256';
import { RECORD_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
@Injectable()
@ -53,9 +52,6 @@ export class UserService {
const user = await this.userRepository.findOne({
where: {
username: username,
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
});
@ -66,9 +62,6 @@ export class UserService {
const user = await this.userRepository.findOne({
where: {
_id: new ObjectId(id),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
});
@ -79,13 +72,10 @@ export class UserService {
const list = await this.userRepository.find({
where: {
username: new RegExp(username),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
skip,
take,
select: ['_id', 'username', 'createDate'],
select: ['_id', 'username', 'createdAt'],
});
return list;
}
@ -96,11 +86,8 @@ export class UserService {
_id: {
$in: idList.map((item) => new ObjectId(item)),
},
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
select: ['_id', 'username', 'createDate'],
select: ['_id', 'username', 'createdAt'],
});
return list;
}

View File

@ -14,13 +14,18 @@ export class FileService {
configKey,
file,
pathPrefix,
filename,
}: {
configKey: string;
file: Express.Multer.File;
pathPrefix: string;
filename?: string;
}) {
const handler = this.getHandler(configKey);
const { key } = await handler.upload(file, { pathPrefix });
const { key } = await handler.upload(file, {
pathPrefix,
filename,
});
const url = await handler.getUrl(key);
return {
key,

View File

@ -1,4 +1,4 @@
import { join, dirname } from 'path';
import { join, dirname, sep } from 'path';
import fse from 'fs-extra';
import { createWriteStream } from 'fs';
import { FileUploadHandler } from './uploadHandler.interface';
@ -12,13 +12,20 @@ export class LocalHandler implements FileUploadHandler {
async upload(
file: Express.Multer.File,
options?: { pathPrefix?: string },
options?: { pathPrefix?: string; filename?: string },
): Promise<{ key: string }> {
const filename = await generateUniqueFilename(file.originalname);
let filename;
if (options?.filename) {
filename = file.filename;
} else {
filename = await generateUniqueFilename(file.originalname);
}
const filePath = join(
options?.pathPrefix ? options?.pathPrefix : '',
filename,
);
)
.split(sep)
.join('/');
const physicalPath = join(this.physicalRootPath, filePath);
await fse.mkdir(dirname(physicalPath), { recursive: true });
const writeStream = createWriteStream(physicalPath);
@ -35,6 +42,10 @@ export class LocalHandler implements FileUploadHandler {
}
getUrl(key: string): string {
if (process.env.SERVER_ENV === 'local') {
const port = process.env.PORT || 3000;
return `http://localhost:${port}/${key}`;
}
return `/${key}`;
}
}

View File

@ -8,7 +8,6 @@ import {
MESSAGE_PUSHING_TYPE,
MESSAGE_PUSHING_HOOK,
} from 'src/enums/messagePushing';
import { RECORD_STATUS } from 'src/enums';
describe('MessagePushingTaskDto', () => {
let dto: MessagePushingTaskDto;
@ -34,9 +33,9 @@ describe('MessagePushingTaskDto', () => {
});
it('should have a type', () => {
dto.type = MESSAGE_PUSHING_TYPE.HTTP; // Set your desired type here
dto.type = MESSAGE_PUSHING_TYPE.HTTP;
expect(dto.type).toBeDefined();
expect(dto.type).toEqual(MESSAGE_PUSHING_TYPE.HTTP); // Adjust based on your enum
expect(dto.type).toEqual(MESSAGE_PUSHING_TYPE.HTTP);
});
it('should have a push address', () => {
@ -46,13 +45,13 @@ describe('MessagePushingTaskDto', () => {
});
it('should have a trigger hook', () => {
dto.triggerHook = MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED; // Set your desired hook here
dto.triggerHook = MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED;
expect(dto.triggerHook).toBeDefined();
expect(dto.triggerHook).toEqual(MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED); // Adjust based on your enum
expect(dto.triggerHook).toEqual(MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED);
});
it('should have an array of surveys', () => {
dto.surveys = ['survey1', 'survey2']; // Set your desired surveys here
dto.surveys = ['survey1', 'survey2'];
expect(dto.surveys).toBeDefined();
expect(dto.surveys).toEqual(['survey1', 'survey2']);
});
@ -62,13 +61,6 @@ describe('MessagePushingTaskDto', () => {
expect(dto.owner).toBeDefined();
expect(dto.owner).toBe('test_owner');
});
it('should have current status', () => {
dto.curStatus = { status: RECORD_STATUS.NEW, date: Date.now() };
expect(dto.curStatus).toBeDefined();
expect(dto.curStatus.status).toEqual(RECORD_STATUS.NEW);
expect(dto.curStatus.date).toBeDefined();
});
});
describe('CodeDto', () => {

View File

@ -10,7 +10,6 @@ import { MessagePushingLogService } from '../services/messagePushingLog.service'
import { CreateMessagePushingTaskDto } from '../dto/createMessagePushingTask.dto';
import { UpdateMessagePushingTaskDto } from '../dto/updateMessagePushingTask.dto';
import { RECORD_STATUS } from 'src/enums';
import { MESSAGE_PUSHING_TYPE } from 'src/enums/messagePushing';
import { MESSAGE_PUSHING_HOOK } from 'src/enums/messagePushing';
import { MessagePushingTask } from 'src/models/messagePushingTask.entity';
@ -118,10 +117,12 @@ describe('MessagePushingTaskService', () => {
expect(result).toEqual(tasks);
expect(repository.find).toHaveBeenCalledWith({
where: {
isDeleted: {
$ne: true,
},
ownerId: mockOwnerId,
surveys: { $all: [surveyId] },
triggerHook: hook,
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
});
});
@ -146,10 +147,19 @@ describe('MessagePushingTaskService', () => {
where: {
ownerId: mockOwnerId,
_id: new ObjectId(taskId),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
isDeleted: {
$ne: true,
},
},
});
});
it('should throw an error when message pushing task is not found', async () => {
const taskId = '65afc62904d5db18534c0f78';
jest.spyOn(repository, 'findOne').mockResolvedValue(null); // 模拟未找到任务
const mockOwnerId = '66028642292c50f8b71a9eee';
await expect(service.findOne({ id: taskId, ownerId: mockOwnerId }));
});
});
describe('update', () => {
@ -161,10 +171,6 @@ describe('MessagePushingTaskService', () => {
pushAddress: 'http://update.example.com',
triggerHook: MESSAGE_PUSHING_HOOK.RESPONSE_INSERTED,
surveys: ['new survey id'],
curStatus: {
status: RECORD_STATUS.EDITING,
date: Date.now(),
},
};
const existingTask = new MessagePushingTask();
existingTask._id = new ObjectId(taskId);
@ -190,6 +196,20 @@ describe('MessagePushingTaskService', () => {
});
expect(repository.save).toHaveBeenCalledWith(updatedTask);
});
it('should throw an error if the task to be updated is not found', async () => {
const taskId = '65afc62904d5db18534c0f78';
const updateDto: UpdateMessagePushingTaskDto = { name: 'Updated Task' };
jest.spyOn(repository, 'findOne').mockResolvedValue(null); // 模拟任务未找到
const mockOwnerId = '66028642292c50f8b71a9eee';
await expect(
service.update({
ownerId: mockOwnerId,
id: taskId,
updateData: updateDto,
}),
).rejects.toThrow(`Message pushing task with id ${taskId} not found`);
});
});
describe('remove', () => {
@ -197,38 +217,54 @@ describe('MessagePushingTaskService', () => {
const taskId = '65afc62904d5db18534c0f78';
const updateResult = { modifiedCount: 1 };
const mockOwnerId = '66028642292c50f8b71a9eee';
const mockOperatorId = '66028642292c50f8b71a9eee';
const mockOperator = 'mockOperator';
jest.spyOn(repository, 'updateOne').mockResolvedValue(updateResult);
const result = await service.remove({
id: taskId,
ownerId: mockOwnerId,
operatorId: mockOperatorId,
operator: mockOperator,
});
expect(result).toEqual(updateResult);
expect(repository.updateOne).toHaveBeenCalledWith(
{
ownerId: mockOwnerId,
_id: new ObjectId(taskId),
'curStatus.status': { $ne: RECORD_STATUS.REMOVED },
},
{
$set: {
curStatus: {
status: RECORD_STATUS.REMOVED,
date: expect.any(Number),
},
},
$push: {
statusList: {
status: RECORD_STATUS.REMOVED,
date: expect.any(Number),
},
isDeleted: true,
operatorId: mockOperatorId,
operator: mockOperator,
deletedAt: expect.any(Date),
},
},
);
});
it('should throw an error if the task to be removed is not found', async () => {
const taskId = '65afc62904d5db18534c0f78';
jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({ modifiedCount: 0 }); // 模拟删除失败
const mockOperatorId = '66028642292c50f8b71a9eee';
const mockOperator = 'mockOperator';
const result = await service.remove({
id: taskId,
operatorId: mockOperatorId,
operator: mockOperator,
});
expect(result.modifiedCount).toBe(0);
expect(repository.updateOne).toHaveBeenCalledWith(
{
_id: new ObjectId(taskId),
},
expect.any(Object),
);
});
});
describe('surveyAuthorizeTask', () => {
@ -258,8 +294,35 @@ describe('MessagePushingTaskService', () => {
$push: {
surveys: surveyId,
},
$set: {
updatedAt: expect.any(Date),
},
},
);
});
it('should not add the surveyId if it already exists in the task', async () => {
const taskId = '65afc62904d5db18534c0f78';
const surveyId = '65af380475b64545e5277dd9';
const mockOwnerId = '66028642292c50f8b71a9eee';
jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({ modifiedCount: 0 }); // 模拟重复添加
const result = await service.surveyAuthorizeTask({
taskId,
surveyId,
ownerId: mockOwnerId,
});
expect(result.modifiedCount).toBe(0);
expect(repository.updateOne).toHaveBeenCalledWith(
{
_id: new ObjectId(taskId),
surveys: { $nin: [surveyId] }, // 确保只有不包含时才插入
ownerId: mockOwnerId,
},
expect.any(Object),
);
});
});
});

View File

@ -37,9 +37,4 @@ describe('UpdateMessagePushingTaskDto', () => {
dto.surveys = null;
expect(dto.surveys).toBeNull();
});
it('should have a nullable curStatus', () => {
dto.curStatus = null;
expect(dto.curStatus).toBeNull();
});
});

View File

@ -150,8 +150,9 @@ export class MessagePushingTaskController {
async remove(@Request() req, @Param('id') id: string) {
const userId = req.user._id;
const res = await this.messagePushingTaskService.remove({
ownerId: userId,
id,
operator: req.user.username,
operatorId: userId,
});
return {
code: 200,

View File

@ -4,7 +4,6 @@ import {
MESSAGE_PUSHING_TYPE,
MESSAGE_PUSHING_HOOK,
} from 'src/enums/messagePushing';
import { RECORD_STATUS } from 'src/enums';
export class MessagePushingTaskDto {
@ApiProperty({ description: '任务id' })
@ -27,12 +26,6 @@ export class MessagePushingTaskDto {
@ApiProperty({ description: '所有者' })
owner: string;
@ApiProperty({ description: '任务状态', required: false })
curStatus?: {
status: RECORD_STATUS;
date: number;
};
}
export class CodeDto {

View File

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { RECORD_STATUS } from 'src/enums';
import {
MESSAGE_PUSHING_TYPE,
MESSAGE_PUSHING_HOOK,
@ -20,10 +19,4 @@ export class UpdateMessagePushingTaskDto {
@ApiProperty({ description: '绑定的问卷id', required: false })
surveys?: string[];
@ApiProperty({ description: '任务状态', required: false })
curStatus?: {
status: RECORD_STATUS;
date: number;
};
}

View File

@ -6,7 +6,6 @@ import { MESSAGE_PUSHING_HOOK } from 'src/enums/messagePushing';
import { CreateMessagePushingTaskDto } from '../dto/createMessagePushingTask.dto';
import { UpdateMessagePushingTaskDto } from '../dto/updateMessagePushingTask.dto';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from 'src/enums';
import { MESSAGE_PUSHING_TYPE } from 'src/enums/messagePushing';
import { MessagePushingLogService } from './messagePushingLog.service';
import { httpPost } from 'src/utils/request';
@ -44,8 +43,8 @@ export class MessagePushingTaskService {
ownerId?: string;
}): Promise<MessagePushingTask[]> {
const where: Record<string, any> = {
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
isDeleted: {
$ne: true,
},
};
if (surveyId) {
@ -64,19 +63,19 @@ export class MessagePushingTaskService {
});
}
async findOne({
findOne({
id,
ownerId,
}: {
id: string;
ownerId: string;
}): Promise<MessagePushingTask> {
return await this.messagePushingTaskRepository.findOne({
return this.messagePushingTaskRepository.findOne({
where: {
ownerId,
_id: new ObjectId(id),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
isDeleted: {
$ne: true,
},
},
});
@ -104,25 +103,25 @@ export class MessagePushingTaskService {
return await this.messagePushingTaskRepository.save(updatedTask);
}
async remove({ id, ownerId }: { id: string; ownerId: string }) {
const curStatus = {
status: RECORD_STATUS.REMOVED,
date: Date.now(),
};
async remove({
id,
operator,
operatorId,
}: {
id: string;
operator: string;
operatorId: string;
}) {
return this.messagePushingTaskRepository.updateOne(
{
ownerId,
_id: new ObjectId(id),
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},
},
{
$set: {
curStatus,
},
$push: {
statusList: curStatus as never,
isDeleted: true,
operator,
operatorId,
deletedAt: new Date(),
},
},
);
@ -147,6 +146,9 @@ export class MessagePushingTaskService {
$push: {
surveys: surveyId as never,
},
$set: {
updatedAt: new Date(),
},
},
);
}

View File

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

View File

@ -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<Lock> {
return this.redlock.acquire([resource], ttl);
}
async unlockResource(lock: Lock): Promise<void> {
await lock.release();
}
}

View File

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

View File

@ -75,6 +75,25 @@ describe('CollaboratorService', () => {
});
});
describe('deleteCollaborator', () => {
it('should delete a collaborator by userId and surveyId', async () => {
const deleteOneSpy = jest
.spyOn(repository, 'deleteOne')
.mockResolvedValue({ acknowledged: true, deletedCount: 1 });
const result = await service.deleteCollaborator({
userId: '1',
surveyId: '1',
});
expect(deleteOneSpy).toHaveBeenCalledWith({
userId: '1',
surveyId: '1',
});
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
});
describe('batchCreate', () => {
it('should batch create collaborators', async () => {
const insertManySpy = jest
@ -86,15 +105,193 @@ describe('CollaboratorService', () => {
const result = await service.batchCreate({
surveyId: '1',
collaboratorList: [{ userId: '1', permissions: [] }],
creator: 'testCreator',
creatorId: 'testCreatorId',
});
expect(insertManySpy).toHaveBeenCalledWith([
{ surveyId: '1', userId: '1', permissions: [] },
{
userId: '1',
permissions: [],
surveyId: '1',
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
creator: 'testCreator',
creatorId: 'testCreatorId',
},
]);
expect(result).toEqual({ insertedCount: 1 });
});
});
describe('changeUserPermission', () => {
it("should update a user's permissions", async () => {
const updateOneSpy = jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({});
const result = await service.changeUserPermission({
userId: '1',
surveyId: '1',
permission: 'read',
operator: 'testOperator',
operatorId: 'testOperatorId',
});
expect(updateOneSpy).toHaveBeenCalledWith(
{
surveyId: '1',
userId: '1',
},
{
$set: {
permission: 'read',
operator: 'testOperator',
operatorId: 'testOperatorId',
updatedAt: expect.any(Date),
},
},
);
expect(result).toEqual({});
});
});
describe('batchDelete', () => {
it('should batch delete collaborators', async () => {
const mockResult = { acknowledged: true, deletedCount: 1 };
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue(mockResult);
const collaboratorId = new ObjectId().toString();
const result = await service.batchDelete({
surveyId: '1',
idList: [collaboratorId],
});
const expectedQuery = {
surveyId: '1',
$or: [
{
_id: {
$in: [new ObjectId(collaboratorId)],
},
},
],
};
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(expectedQuery));
expect(deleteManySpy).toHaveBeenCalledWith(expectedQuery);
expect(result).toEqual(mockResult);
});
it('should batch delete collaborators by idList and userIdList', async () => {
const collaboratorId = new ObjectId().toString();
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue({ acknowledged: true, deletedCount: 2 });
const result = await service.batchDelete({
idList: [collaboratorId],
userIdList: ['user1', 'user2'],
surveyId: '1',
});
const expectedQuery = {
surveyId: '1',
$or: [
{
userId: {
$in: ['user1', 'user2'],
},
},
{
_id: {
$in: [new ObjectId(collaboratorId)],
},
},
],
};
expect(deleteManySpy).toHaveBeenCalledWith(expectedQuery);
expect(result).toEqual({ acknowledged: true, deletedCount: 2 });
});
it('should handle batch delete with neIdList only', async () => {
const neCollaboratorId = new ObjectId().toString();
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue({ acknowledged: true, deletedCount: 1 });
const result = await service.batchDelete({
neIdList: [neCollaboratorId],
surveyId: '1',
});
const expectedQuery = {
surveyId: '1',
$or: [
{
_id: {
$nin: [new ObjectId(neCollaboratorId)],
},
},
],
};
expect(deleteManySpy).toHaveBeenCalledWith(expectedQuery);
expect(result).toEqual({ acknowledged: true, deletedCount: 1 });
});
});
describe('batchDeleteBySurveyId', () => {
it('should batch delete collaborators by survey id', async () => {
const mockResult = { acknowledged: true, deletedCount: 1 };
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue(mockResult);
const surveyId = new ObjectId().toString();
const result = await service.batchDeleteBySurveyId(surveyId);
expect(deleteManySpy).toHaveBeenCalledWith({
surveyId,
});
expect(result).toEqual(mockResult);
});
});
describe('updateById', () => {
it('should update collaborator by id', async () => {
const updateOneSpy = jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({});
const collaboratorId = new ObjectId().toString();
const result = await service.updateById({
collaboratorId,
permissions: [],
operator: 'testOperator',
operatorId: 'testOperatorId',
});
expect(updateOneSpy).toHaveBeenCalledWith(
{
_id: new ObjectId(collaboratorId),
},
{
$set: {
permissions: [],
operator: 'testOperator',
operatorId: 'testOperatorId',
updatedAt: expect.any(Date),
},
},
);
expect(result).toEqual({});
});
});
describe('getSurveyCollaboratorList', () => {
it('should return a list of collaborators for a survey', async () => {
const collaboratorId = new ObjectId().toString();
@ -121,38 +318,6 @@ describe('CollaboratorService', () => {
});
});
describe('getCollaboratorListByIds', () => {
it('should return a list of collaborators by ids', async () => {
const collaboratorId = new ObjectId().toString();
const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([
{
_id: new ObjectId(collaboratorId),
surveyId: '1',
userId: '1',
permissions: [],
},
] as Collaborator[]);
const result = await service.getCollaboratorListByIds({
idList: [collaboratorId],
});
expect(findSpy).toHaveBeenCalledWith({
_id: {
$in: [new ObjectId(collaboratorId)],
},
});
expect(result).toEqual([
{
_id: new ObjectId(collaboratorId),
surveyId: '1',
userId: '1',
permissions: [],
},
]);
});
});
describe('getCollaborator', () => {
it('should return a collaborator', async () => {
const collaboratorId = new ObjectId().toString();
@ -183,127 +348,6 @@ describe('CollaboratorService', () => {
});
});
describe('changeUserPermission', () => {
it("should update a user's permissions", async () => {
const updateOneSpy = jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({});
const result = await service.changeUserPermission({
userId: '1',
surveyId: '1',
permission: 'read',
});
expect(updateOneSpy).toHaveBeenCalledWith(
{
surveyId: '1',
userId: '1',
},
{
$set: {
permission: 'read',
},
},
);
expect(result).toEqual({});
});
});
describe('deleteCollaborator', () => {
it('should delete a collaborator', async () => {
const mockResult = { acknowledged: true, deletedCount: 1 };
const deleteOneSpy = jest
.spyOn(repository, 'deleteOne')
.mockResolvedValue(mockResult);
const result = await service.deleteCollaborator({
userId: '1',
surveyId: '1',
});
expect(deleteOneSpy).toHaveBeenCalledWith({
userId: '1',
surveyId: '1',
});
expect(result).toEqual(mockResult);
});
});
describe('batchDelete', () => {
it('should batch delete collaborators', async () => {
const mockResult = { acknowledged: true, deletedCount: 1 };
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue(mockResult);
const collaboratorId = new ObjectId().toString();
const result = await service.batchDelete({
surveyId: '1',
idList: [collaboratorId],
});
const expectedQuery = {
surveyId: '1',
$or: [
{
_id: {
$in: [new ObjectId(collaboratorId)],
},
},
],
};
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(expectedQuery));
expect(deleteManySpy).toHaveBeenCalledWith(expectedQuery);
expect(result).toEqual(mockResult);
});
});
describe('batchDeleteBySurveyId', () => {
it('should batch delete collaborators by survey id', async () => {
const mockResult = { acknowledged: true, deletedCount: 1 };
const deleteManySpy = jest
.spyOn(repository, 'deleteMany')
.mockResolvedValue(mockResult);
const surveyId = new ObjectId().toString();
const result = await service.batchDeleteBySurveyId(surveyId);
expect(deleteManySpy).toHaveBeenCalledWith({
surveyId,
});
expect(result).toEqual(mockResult);
});
});
describe('updateById', () => {
it('should update collaborator by id', async () => {
const updateOneSpy = jest
.spyOn(repository, 'updateOne')
.mockResolvedValue({});
const collaboratorId = new ObjectId().toString();
const result = await service.updateById({
collaboratorId,
permissions: [],
});
expect(updateOneSpy).toHaveBeenCalledWith(
{
_id: new ObjectId(collaboratorId),
},
{
$set: {
permissions: [],
},
},
);
expect(result).toEqual({});
});
});
describe('getCollaboratorListByUserId', () => {
it('should return a list of collaborators by user id', async () => {
const userId = new ObjectId().toString();
@ -327,5 +371,48 @@ describe('CollaboratorService', () => {
{ _id: '1', surveyId: '1', userId, permissions: [] },
]);
});
it('should return a list of collaborators by their IDs', async () => {
const collaboratorId1 = new ObjectId().toString();
const collaboratorId2 = new ObjectId().toString();
const findSpy = jest.spyOn(repository, 'find').mockResolvedValue([
{
_id: new ObjectId(collaboratorId1),
surveyId: '1',
userId: 'user1',
permissions: [],
},
{
_id: new ObjectId(collaboratorId2),
surveyId: '2',
userId: 'user2',
permissions: [],
},
] as Collaborator[]);
const result = await service.getCollaboratorListByIds({
idList: [collaboratorId1, collaboratorId2],
});
expect(findSpy).toHaveBeenCalledWith({
_id: {
$in: [new ObjectId(collaboratorId1), new ObjectId(collaboratorId2)],
},
});
expect(result).toEqual([
{
_id: new ObjectId(collaboratorId1),
surveyId: '1',
userId: 'user1',
permissions: [],
},
{
_id: new ObjectId(collaboratorId2),
surveyId: '2',
userId: 'user2',
permissions: [],
},
]);
});
});
});

View File

@ -8,7 +8,7 @@ import { SurveyMetaService } from '../services/surveyMeta.service';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { PluginManager } from 'src/securityPlugin/pluginManager';
import { Logger } from 'src/logger';
import { UserService } from 'src/modules/auth/services/user.service';
@ -27,7 +27,7 @@ describe('DataStatisticController', () => {
let controller: DataStatisticController;
let dataStatisticService: DataStatisticService;
let responseSchemaService: ResponseSchemaService;
let pluginManager: XiaojuSurveyPluginManager;
let pluginManager: PluginManager;
let logger: Logger;
beforeEach(async () => {
@ -70,9 +70,7 @@ describe('DataStatisticController', () => {
responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
pluginManager = module.get<PluginManager>(PluginManager);
logger = module.get<Logger>(Logger);
pluginManager.registerPlugin(
@ -90,7 +88,7 @@ describe('DataStatisticController', () => {
const mockRequest = {
query: {
surveyId,
isDesensitive: false,
isMasked: false,
page: 1,
pageSize: 10,
},
@ -106,12 +104,13 @@ describe('DataStatisticController', () => {
field: 'xxx',
title: 'xxx',
type: 'xxx',
diffTime: 'xxx',
othersCode: 'xxx',
},
],
listBody: [
{ difTime: '0.5', createDate: '2024-02-11' },
{ difTime: '0.5', createDate: '2024-02-11' },
{ diffTime: '0.5', createdAt: '2024-02-11' },
{ diffTime: '0.5', createdAt: '2024-02-11' },
],
};
@ -122,7 +121,7 @@ describe('DataStatisticController', () => {
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
const result = await controller.data(mockRequest.query);
expect(result).toEqual({
code: 200,
@ -130,12 +129,12 @@ describe('DataStatisticController', () => {
});
});
it('should return data table with isDesensitive', async () => {
it('should return data table with isMasked', async () => {
const surveyId = new ObjectId().toString();
const mockRequest = {
query: {
surveyId,
isDesensitive: true,
isMasked: true,
page: 1,
pageSize: 10,
},
@ -151,12 +150,13 @@ describe('DataStatisticController', () => {
field: 'xxx',
title: 'xxx',
type: 'xxx',
diffTime: 'xxx',
othersCode: 'xxx',
},
],
listBody: [
{ difTime: '0.5', createDate: '2024-02-11', data123: '15200000000' },
{ difTime: '0.5', createDate: '2024-02-11', data123: '13800000000' },
{ diffTime: '0.5', createdAt: '2024-02-11', data123: '15200000000' },
{ diffTime: '0.5', createdAt: '2024-02-11', data123: '13800000000' },
],
};
@ -167,7 +167,7 @@ describe('DataStatisticController', () => {
.spyOn(dataStatisticService, 'getDataTable')
.mockResolvedValueOnce(mockDataTable);
const result = await controller.data(mockRequest.query, mockRequest);
const result = await controller.data(mockRequest.query);
expect(result).toEqual({
code: 200,
@ -185,9 +185,9 @@ describe('DataStatisticController', () => {
},
};
await expect(
controller.data(mockRequest.query, mockRequest),
).rejects.toThrow(HttpException);
await expect(controller.data(mockRequest.query)).rejects.toThrow(
HttpException,
);
expect(logger.error).toHaveBeenCalledTimes(1);
});
});
@ -212,8 +212,8 @@ describe('DataStatisticController', () => {
date: 1717158851823,
},
],
createDate: 1717158851823,
updateDate: 1717159136025,
createdAt: 1717158851823,
updatedAt: 1717159136025,
title: '问卷调研',
surveyPath: 'ZdGNzTTR',
code: {
@ -233,7 +233,7 @@ describe('DataStatisticController', () => {
},
},
baseConf: {
begTime: '2024-05-31 20:31:36',
beginTime: '2024-05-31 20:31:36',
endTime: '2034-05-31 20:31:36',
language: 'chinese',
showVoteProcess: 'allow',
@ -249,6 +249,8 @@ describe('DataStatisticController', () => {
skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
@ -273,6 +275,11 @@ describe('DataStatisticController', () => {
again_text: '确认要提交吗?',
},
link: '',
jumpConfig: {
type: 'link',
link: '',
buttonText: '',
},
},
logicConf: {
showLogicConf: [],

View File

@ -11,7 +11,7 @@ import { cloneDeep } from 'lodash';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RECORD_STATUS } from 'src/enums';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { PluginManager } from 'src/securityPlugin/pluginManager';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
describe('DataStatisticService', () => {
@ -34,9 +34,7 @@ describe('DataStatisticService', () => {
surveyResponseRepository = module.get<MongoRepository<SurveyResponse>>(
getRepositoryToken(SurveyResponse),
);
const manager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
const manager = module.get<PluginManager>(PluginManager);
manager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
@ -70,7 +68,7 @@ describe('DataStatisticService', () => {
data413: 3,
data863: '109239',
},
difTime: 21278,
diffTime: 21278,
clientTime: 1710340862733.0,
secretKeys: [],
optionTextAndId: {
@ -153,8 +151,8 @@ describe('DataStatisticService', () => {
date: 1710340863123.0,
},
],
createDate: 1710340863123.0,
updateDate: 1710340863123.0,
createdAt: 1710340863123.0,
updatedAt: 1710340863123.0,
},
] as unknown as Array<SurveyResponse>;
@ -197,14 +195,14 @@ describe('DataStatisticService', () => {
data413_3: expect.any(String),
data413: expect.any(Number),
data863: expect.any(String),
difTime: expect.any(String),
createDate: expect.any(String),
diffTime: expect.any(String),
createdAt: expect.any(String),
}),
]),
});
});
it('should return desensitive table data', async () => {
it('should return desensitized table data', async () => {
const mockSchema = cloneDeep(mockSensitiveResponseSchema);
const surveyResponseList: Array<SurveyResponse> = [
{
@ -220,7 +218,7 @@ describe('DataStatisticService', () => {
'U2FsdGVkX19bRmf3uEmXAJ/6zXd1Znr3cZsD5v4Nocr2v5XG1taXluz8cohFkDyH',
data770: 'U2FsdGVkX18ldQMhJjFXO8aerjftZLpFnRQ4/FVcCLI=',
},
difTime: 806707,
diffTime: 806707,
clientTime: 1710400229573.0,
secretKeys: ['data458', 'data450', 'data405', 'data770'],
optionTextAndId: {
@ -275,8 +273,8 @@ describe('DataStatisticService', () => {
date: 1710400232161.0,
},
],
createDate: 1710400232161.0,
updateDate: 1710400232161.0,
createdAt: 1710400232161.0,
updatedAt: 1710400232161.0,
},
] as unknown as Array<SurveyResponse>;
@ -297,13 +295,13 @@ describe('DataStatisticService', () => {
expect(result.listBody).toEqual(
expect.arrayContaining([
expect.objectContaining({
createDate: expect.any(String),
createdAt: expect.any(String),
data405: expect.any(String),
data450: expect.any(String),
data458: expect.any(String),
data515: expect.any(String),
data770: expect.any(String),
difTime: expect.any(String),
diffTime: expect.any(String),
}),
]),
);

View File

@ -0,0 +1,264 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ObjectId } from 'mongodb';
import { DownloadTaskController } from '../controllers/downloadTask.controller';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { DownloadTaskService } from '../services/downloadTask.service';
import { CollaboratorService } from '../services/collaborator.service';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyGuard } from 'src/guards/survey.guard';
describe('DownloadTaskController', () => {
let controller: DownloadTaskController;
let responseSchemaService: ResponseSchemaService;
let downloadTaskService: DownloadTaskService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DownloadTaskController],
providers: [
{
provide: ResponseSchemaService,
useValue: {
getResponseSchemaByPageId: jest.fn(),
},
},
{
provide: DownloadTaskService,
useValue: {
createDownloadTask: jest.fn(),
processDownloadTask: jest.fn(),
getDownloadTaskList: jest.fn(),
getDownloadTaskById: jest.fn(),
deleteDownloadTask: jest.fn(),
},
},
{
provide: Logger,
useValue: {
error: jest.fn(),
},
},
{
provide: AuthService,
useClass: jest.fn().mockImplementation(() => ({
varifytoken() {
return {};
},
})),
},
{
provide: CollaboratorService,
useValue: {},
},
{
provide: SurveyMetaService,
useValue: {},
},
{
provide: WorkspaceMemberService,
useValue: {},
},
{
provide: Authentication,
useClass: jest.fn().mockImplementation(() => ({
canActivate: () => true,
})),
},
{
provide: SurveyGuard,
useClass: jest.fn().mockImplementation(() => ({
canActivate: () => true,
})),
},
],
}).compile();
controller = module.get<DownloadTaskController>(DownloadTaskController);
responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
downloadTaskService = module.get<DownloadTaskService>(DownloadTaskService);
});
describe('createTask', () => {
it('should create a download task successfully', async () => {
const mockReqBody = {
surveyId: new ObjectId().toString(),
isMasked: false,
};
const mockReq = { user: { _id: 'mockUserId', username: 'mockUsername' } };
const mockTaskId = 'mockTaskId';
jest
.spyOn(responseSchemaService, 'getResponseSchemaByPageId')
.mockResolvedValue({} as any);
jest
.spyOn(downloadTaskService, 'createDownloadTask')
.mockResolvedValue(mockTaskId);
const result = await controller.createTask(mockReqBody, mockReq);
expect(
responseSchemaService.getResponseSchemaByPageId,
).toHaveBeenCalledWith(mockReqBody.surveyId);
expect(downloadTaskService.createDownloadTask).toHaveBeenCalledWith({
surveyId: mockReqBody.surveyId,
responseSchema: {},
creatorId: mockReq.user._id.toString(),
creator: mockReq.user.username,
params: { isMasked: mockReqBody.isMasked },
});
expect(downloadTaskService.processDownloadTask).toHaveBeenCalledWith({
taskId: mockTaskId,
});
expect(result).toEqual({ code: 200, data: { taskId: mockTaskId } });
});
it('should throw HttpException if validation fails', async () => {
const mockReqBody: any = { isMasked: false };
const mockReq = { user: { _id: 'mockUserId', username: 'mockUsername' } };
await expect(controller.createTask(mockReqBody, mockReq)).rejects.toThrow(
HttpException,
);
});
});
describe('downloadList', () => {
it('should return the download task list', async () => {
const mockQueryInfo = { pageIndex: 1, pageSize: 10 };
const mockReq = { user: { _id: 'mockUserId' } };
const mockTaskList: any = {
total: 1,
list: [
{
_id: 'mockTaskId',
curStatus: 'completed',
filename: 'mockFile.csv',
url: 'http://mock-url.com',
fileSize: 1024,
createdAt: Date.now(),
},
],
};
jest
.spyOn(downloadTaskService, 'getDownloadTaskList')
.mockResolvedValue(mockTaskList);
const result = await controller.downloadList(mockQueryInfo, mockReq);
expect(downloadTaskService.getDownloadTaskList).toHaveBeenCalledWith({
creatorId: mockReq.user._id.toString(),
pageIndex: mockQueryInfo.pageIndex,
pageSize: mockQueryInfo.pageSize,
});
expect(result.data.total).toEqual(mockTaskList.total);
expect(result.data.list[0].taskId).toEqual(
mockTaskList.list[0]._id.toString(),
);
});
it('should throw HttpException if validation fails', async () => {
const mockQueryInfo: any = { pageIndex: 'invalid', pageSize: 10 };
const mockReq = { user: { _id: 'mockUserId' } };
await expect(
controller.downloadList(mockQueryInfo, mockReq),
).rejects.toThrow(HttpException);
});
});
describe('getDownloadTask', () => {
it('should return a download task', async () => {
const mockQuery = { taskId: 'mockTaskId' };
const mockReq = { user: { _id: 'mockUserId' } };
const mockTaskInfo: any = {
_id: 'mockTaskId',
creatorId: 'mockUserId',
curStatus: 'completed',
};
jest
.spyOn(downloadTaskService, 'getDownloadTaskById')
.mockResolvedValue(mockTaskInfo);
const result = await controller.getDownloadTask(mockQuery, mockReq);
expect(downloadTaskService.getDownloadTaskById).toHaveBeenCalledWith({
taskId: mockQuery.taskId,
});
expect(result.data.taskId).toEqual(mockTaskInfo._id.toString());
});
it('should throw NoPermissionException if user has no permission', async () => {
const mockQuery = { taskId: 'mockTaskId' };
const mockReq = { user: { _id: new ObjectId() } };
const mockTaskInfo: any = {
_id: 'mockTaskId',
creatorId: 'mockUserId',
curStatus: 'completed',
};
jest
.spyOn(downloadTaskService, 'getDownloadTaskById')
.mockResolvedValue(mockTaskInfo);
await expect(
controller.getDownloadTask(mockQuery, mockReq),
).rejects.toThrow(new NoPermissionException('没有权限'));
});
});
describe('deleteFileByName', () => {
it('should delete a download task successfully', async () => {
const mockBody = { taskId: 'mockTaskId' };
const mockUserId = new ObjectId();
const mockReq = {
user: { _id: mockUserId, username: 'mockUsername' },
};
const mockTaskInfo: any = {
_id: new ObjectId(),
creatorId: mockUserId.toString(),
};
const mockDelRes = { modifiedCount: 1 };
jest
.spyOn(downloadTaskService, 'getDownloadTaskById')
.mockResolvedValue(mockTaskInfo);
jest
.spyOn(downloadTaskService, 'deleteDownloadTask')
.mockResolvedValue(mockDelRes);
const result = await controller.deleteFileByName(mockBody, mockReq);
expect(downloadTaskService.deleteDownloadTask).toHaveBeenCalledWith({
taskId: mockBody.taskId,
operator: mockReq.user.username,
operatorId: mockReq.user._id.toString(),
});
expect(result).toEqual({ code: 200, data: true });
});
it('should throw HttpException if task does not exist', async () => {
const mockBody = { taskId: 'mockTaskId' };
const mockReq = { user: { _id: 'mockUserId' } };
jest
.spyOn(downloadTaskService, 'getDownloadTaskById')
.mockResolvedValue(null);
await expect(
controller.deleteFileByName(mockBody, mockReq),
).rejects.toThrow(
new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR),
);
});
});
});

View File

@ -0,0 +1,245 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DownloadTaskService } from '../services/downloadTask.service';
import { MongoRepository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DownloadTask } from 'src/models/downloadTask.entity';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { ResponseSchemaService } from 'src/modules/surveyResponse/services/responseScheme.service';
import { DataStatisticService } from '../services/dataStatistic.service';
import { FileService } from 'src/modules/file/services/file.service';
import { Logger } from 'src/logger';
import { ObjectId } from 'mongodb';
import { DOWNLOAD_TASK_STATUS } from 'src/enums/downloadTaskStatus';
describe('DownloadTaskService', () => {
let service: DownloadTaskService;
let downloadTaskRepository: MongoRepository<DownloadTask>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DownloadTaskService,
{
provide: getRepositoryToken(DownloadTask),
useClass: MongoRepository,
},
{
provide: getRepositoryToken(SurveyResponse),
useClass: MongoRepository,
},
{
provide: ResponseSchemaService,
useValue: {
getResponseSchemaByPageId: jest.fn(),
},
},
{
provide: DataStatisticService,
useValue: {
getDataTable: jest.fn(),
},
},
{
provide: FileService,
useValue: {
upload: jest.fn(),
},
},
{
provide: Logger,
useValue: {
info: jest.fn(),
error: jest.fn(),
},
},
],
}).compile();
service = module.get<DownloadTaskService>(DownloadTaskService);
downloadTaskRepository = module.get<MongoRepository<DownloadTask>>(
getRepositoryToken(DownloadTask),
);
});
describe('createDownloadTask', () => {
it('should create and save a download task', async () => {
const mockTaskId = new ObjectId().toString();
const mockDownloadTask = { _id: new ObjectId(mockTaskId) };
const mockParams: any = {
surveyId: 'survey1',
responseSchema: { title: 'test-title', surveyPath: '/path' },
creatorId: 'creator1',
creator: 'creatorName',
params: { isMasked: true },
};
jest
.spyOn(downloadTaskRepository, 'create')
.mockReturnValue(mockDownloadTask as any);
jest
.spyOn(downloadTaskRepository, 'save')
.mockResolvedValue(mockDownloadTask as any);
const result = await service.createDownloadTask(mockParams);
expect(downloadTaskRepository.create).toHaveBeenCalledWith({
surveyId: mockParams.surveyId,
surveyPath: mockParams.responseSchema.surveyPath,
fileSize: '计算中',
creatorId: mockParams.creatorId,
creator: mockParams.creator,
params: {
...mockParams.params,
title: mockParams.responseSchema.title,
},
filename: expect.any(String),
status: DOWNLOAD_TASK_STATUS.WAITING,
});
expect(downloadTaskRepository.save).toHaveBeenCalled();
expect(result).toEqual(mockTaskId);
});
});
describe('getDownloadTaskList', () => {
it('should return task list and total count', async () => {
const mockCreatorId = 'creator1';
const mockTasks = [{ _id: '1' }, { _id: '2' }];
const mockTotal = 2;
jest
.spyOn(downloadTaskRepository, 'findAndCount')
.mockResolvedValue([mockTasks as any, mockTotal]);
const result = await service.getDownloadTaskList({
creatorId: mockCreatorId,
pageIndex: 1,
pageSize: 10,
});
expect(downloadTaskRepository.findAndCount).toHaveBeenCalledWith({
where: {
creatorId: mockCreatorId,
isDeleted: { $ne: true },
},
take: 10,
skip: 0,
order: { createdAt: -1 },
});
expect(result).toEqual({
total: mockTotal,
list: mockTasks,
});
});
});
describe('getDownloadTaskById', () => {
it('should return task by id', async () => {
const mockTaskId = new ObjectId().toString();
const mockTask = { _id: new ObjectId(mockTaskId) };
jest
.spyOn(downloadTaskRepository, 'find')
.mockResolvedValue([mockTask as any]);
const result = await service.getDownloadTaskById({ taskId: mockTaskId });
expect(downloadTaskRepository.find).toHaveBeenCalledWith({
where: { _id: new ObjectId(mockTaskId) },
});
expect(result).toEqual(mockTask);
});
it('should return null if task is not found', async () => {
const mockTaskId = new ObjectId().toString();
jest.spyOn(downloadTaskRepository, 'find').mockResolvedValue([]);
const result = await service.getDownloadTaskById({ taskId: mockTaskId });
expect(result).toBeNull();
});
});
describe('deleteDownloadTask', () => {
it('should mark task as deleted and set deletedAt', async () => {
const mockTaskId = new ObjectId().toString();
const mockOperator = 'operatorName';
const mockOperatorId = 'operatorId1';
const mockUpdateResult = { matchedCount: 1 };
jest
.spyOn(downloadTaskRepository, 'updateOne')
.mockResolvedValue(mockUpdateResult as any);
const result = await service.deleteDownloadTask({
taskId: mockTaskId,
operator: mockOperator,
operatorId: mockOperatorId,
});
expect(downloadTaskRepository.updateOne).toHaveBeenCalledWith(
{ _id: new ObjectId(mockTaskId) },
{
$set: {
isDeleted: true,
operator: mockOperator,
operatorId: mockOperatorId,
deletedAt: expect.any(Date),
},
},
);
expect(result).toEqual(mockUpdateResult);
});
});
describe('processDownloadTask', () => {
it('should push task to queue and execute if not executing', async () => {
const mockTaskId = new ObjectId().toString();
jest.spyOn(service, 'executeTask').mockImplementation(jest.fn());
service.processDownloadTask({ taskId: mockTaskId });
expect(DownloadTaskService.taskList).toContain(mockTaskId);
expect(service.executeTask).toHaveBeenCalled();
});
it('should handle already executing case', async () => {
const mockTaskId = new ObjectId().toString();
DownloadTaskService.isExecuting = true;
jest.spyOn(service, 'executeTask').mockImplementation(jest.fn());
service.processDownloadTask({ taskId: mockTaskId });
expect(DownloadTaskService.taskList).toContain(mockTaskId);
expect(service.executeTask).not.toHaveBeenCalled();
});
});
describe('executeTask', () => {
it('should process and execute tasks in queue', async () => {
const mockTaskId = new ObjectId().toString();
DownloadTaskService.taskList.push(mockTaskId);
jest.spyOn(service, 'getDownloadTaskById').mockResolvedValue({
_id: new ObjectId(mockTaskId),
isDeleted: false,
} as any);
jest.spyOn(service, 'handleDownloadTask').mockResolvedValue(undefined);
await service.executeTask();
expect(service.getDownloadTaskById).toHaveBeenCalledWith({
taskId: mockTaskId,
});
expect(service.handleDownloadTask).toHaveBeenCalled();
});
it('should stop executing when queue is empty', async () => {
DownloadTaskService.taskList = [];
await service.executeTask();
expect(DownloadTaskService.isExecuting).toBe(false);
});
});
});

View File

@ -14,8 +14,8 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
date: 1710399368439,
},
],
createDate: 1710399368440,
updateDate: 1710399368440,
createdAt: 1710399368440,
updatedAt: 1710399368440,
title: '加密全流程',
surveyPath: 'EBzdmnSp',
code: {
@ -32,7 +32,7 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
},
},
baseConf: {
begTime: '2024-03-14 14:54:41',
beginTime: '2024-03-14 14:54:41',
endTime: '2034-03-14 14:54:41',
language: 'chinese',
tLimit: 0,
@ -44,6 +44,17 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
logoImageWidth: '60%',
},
skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
@ -60,6 +71,11 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
is_again: true,
again_text: '确认要提交吗?',
},
jumpConfig: {
type: 'link',
link: '',
buttonText: '',
},
},
dataConf: {
dataList: [
@ -284,7 +300,7 @@ export const mockSensitiveResponseSchema: ResponseSchema = {
},
},
pageId: '65f29f3192862d6a9067ad1c',
} as ResponseSchema;
} as unknown as ResponseSchema;
export const mockResponseSchema: ResponseSchema = {
_id: new ObjectId('65b0d46e04d5db18534c0f7c'),
@ -315,7 +331,7 @@ export const mockResponseSchema: ResponseSchema = {
},
},
baseConf: {
begTime: '2024-01-23 21:59:05',
beginTime: '2024-01-23 21:59:05',
endTime: '2034-01-23 21:59:05',
language: 'chinese',
tLimit: 0,
@ -327,6 +343,17 @@ export const mockResponseSchema: ResponseSchema = {
logoImageWidth: '60%',
},
skinConf: {
backgroundConf: {
color: '#fff',
type: 'color',
image: '',
},
themeConf: {
color: '#ffa600',
},
contentConf: {
opacity: 100,
},
skinColor: '#4a4c5b',
inputBgColor: '#ffffff',
},
@ -343,6 +370,11 @@ export const mockResponseSchema: ResponseSchema = {
is_again: true,
again_text: '确认要提交吗?',
},
jumpConfig: {
type: 'link',
link: '',
buttonText: '',
},
},
dataConf: {
dataList: [
@ -632,6 +664,6 @@ export const mockResponseSchema: ResponseSchema = {
},
},
pageId: '65afc62904d5db18534c0f78',
createDate: 1710340841289,
updateDate: 1710340841289.0,
} as ResponseSchema;
createdAt: 1710340841289,
updatedAt: 1710340841289.0,
} as unknown as ResponseSchema;

View File

@ -0,0 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SessionController } from '../controllers/session.controller';
import { SessionService } from '../services/session.service';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SessionGuard } from 'src/guards/session.guard';
describe('SessionController', () => {
let controller: SessionController;
let sessionService: jest.Mocked<SessionService>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SessionController],
providers: [
{
provide: SessionService,
useValue: {
create: jest.fn(),
updateSessionToEditing: jest.fn(),
},
},
{
provide: Logger,
useValue: {
error: jest.fn(),
},
},
],
})
.overrideGuard(Authentication)
.useValue({ canActivate: () => true })
.overrideGuard(SurveyGuard)
.useValue({ canActivate: () => true })
.overrideGuard(SessionGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<SessionController>(SessionController);
sessionService = module.get<jest.Mocked<SessionService>>(SessionService);
logger = module.get<jest.Mocked<Logger>>(Logger);
});
it('should create a session', async () => {
const reqBody = { surveyId: '123' };
const req = { user: { _id: 'userId' } };
const session: any = { _id: 'sessionId' };
sessionService.create.mockResolvedValue(session);
const result = await controller.create(reqBody, req);
expect(sessionService.create).toHaveBeenCalledWith({
surveyId: '123',
userId: 'userId',
});
expect(result).toEqual({ code: 200, data: { sessionId: 'sessionId' } });
});
it('should throw an exception if validation fails', async () => {
const reqBody = { surveyId: null };
const req = { user: { _id: 'userId' } };
try {
await controller.create(reqBody, req);
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(logger.error).toHaveBeenCalled();
}
});
it('should seize a session', async () => {
const req = {
sessionInfo: { _id: 'sessionId', surveyId: 'surveyId' },
};
await controller.seize(req);
expect(sessionService.updateSessionToEditing).toHaveBeenCalledWith({
sessionId: 'sessionId',
surveyId: 'surveyId',
});
});
});

View File

@ -0,0 +1,144 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MongoRepository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SessionService } from '../services/session.service';
import { Session } from 'src/models/session.entity';
import { ObjectId } from 'mongodb';
import { SESSION_STATUS } from 'src/enums/surveySessionStatus';
describe('SessionService', () => {
let service: SessionService;
let repository: MongoRepository<Session>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SessionService,
{
provide: getRepositoryToken(Session),
useClass: MongoRepository,
},
],
}).compile();
service = module.get<SessionService>(SessionService);
repository = module.get<MongoRepository<Session>>(
getRepositoryToken(Session),
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('should create and save a new session', async () => {
const mockSession = {
surveyId: 'survey123',
userId: 'user123',
status: SESSION_STATUS.DEACTIVATED,
};
const createdSession: any = { ...mockSession, _id: new ObjectId() };
jest.spyOn(repository, 'create').mockReturnValue(createdSession);
jest.spyOn(repository, 'save').mockResolvedValue(createdSession);
const result = await service.create({
surveyId: mockSession.surveyId,
userId: mockSession.userId,
});
expect(result).toEqual(createdSession);
expect(repository.create).toHaveBeenCalledWith(mockSession);
expect(repository.save).toHaveBeenCalledWith(createdSession);
});
});
describe('findOne', () => {
it('should find a session by id', async () => {
const sessionId = '65afc62904d5db18534c0f78';
const foundSession = {
_id: new ObjectId(sessionId),
surveyId: 'survey123',
userId: 'user123',
status: SESSION_STATUS.ACTIVATED,
};
jest
.spyOn(repository, 'findOne')
.mockResolvedValue(foundSession as Session);
const result = await service.findOne(sessionId);
expect(result).toEqual(foundSession);
expect(repository.findOne).toHaveBeenCalledWith({
where: { _id: new ObjectId(sessionId) },
});
});
});
describe('findLatestEditingOne', () => {
it('should find the latest editing session for a survey', async () => {
const surveyId = 'survey123';
const latestSession = {
_id: new ObjectId(),
surveyId: surveyId,
userId: 'user123',
status: SESSION_STATUS.ACTIVATED,
};
jest
.spyOn(repository, 'findOne')
.mockResolvedValue(latestSession as Session);
const result = await service.findLatestEditingOne({ surveyId });
expect(result).toEqual(latestSession);
expect(repository.findOne).toHaveBeenCalledWith({
where: {
surveyId,
status: SESSION_STATUS.ACTIVATED,
},
});
});
});
describe('updateSessionToEditing', () => {
it('should update a session to editing and deactivate other sessions', async () => {
const sessionId = '65afc62904d5db18534c0f78';
const surveyId = 'survey123';
const updateResult: any = { affected: 1 };
const updateManyResult = { modifiedCount: 1 };
jest.spyOn(repository, 'update').mockResolvedValue(updateResult);
jest.spyOn(repository, 'updateMany').mockResolvedValue(updateManyResult);
const result = await service.updateSessionToEditing({
sessionId,
surveyId,
});
expect(result).toEqual([updateResult, updateManyResult]);
expect(repository.update).toHaveBeenCalledWith(
{ _id: new ObjectId(sessionId) },
{
status: SESSION_STATUS.ACTIVATED,
updatedAt: expect.any(Date),
},
);
expect(repository.updateMany).toHaveBeenCalledWith(
{
surveyId,
_id: { $ne: new ObjectId(sessionId) },
},
{
$set: {
status: SESSION_STATUS.DEACTIVATED,
updatedAt: expect.any(Date),
},
},
);
});
});
});

View File

@ -5,19 +5,20 @@ 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 { SessionService } from '../services/session.service';
import { UserService } from '../../auth/services/user.service';
import { ObjectId } from 'mongodb';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { SurveyConf } from 'src/models/surveyConf.entity';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { LoggerProvider } from 'src/logger/logger.provider';
import { Authentication } from 'src/guards/authentication.guard';
jest.mock('../services/surveyMeta.service');
jest.mock('../services/surveyConf.service');
jest.mock('../../surveyResponse/services/responseScheme.service');
jest.mock('../services/contentSecurity.service');
jest.mock('../services/surveyHistory.service');
jest.mock('../services/session.service');
jest.mock('../../auth/services/user.service');
jest.mock('src/guards/authentication.guard');
jest.mock('src/guards/survey.guard');
jest.mock('src/guards/workspace.guard');
@ -27,8 +28,6 @@ describe('SurveyController', () => {
let surveyMetaService: SurveyMetaService;
let surveyConfService: SurveyConfService;
let responseSchemaService: ResponseSchemaService;
let contentSecurityService: ContentSecurityService;
let surveyHistoryService: SurveyHistoryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -39,7 +38,21 @@ describe('SurveyController', () => {
ResponseSchemaService,
ContentSecurityService,
SurveyHistoryService,
LoggerProvider,
SessionService,
UserService,
{
provide: Logger,
useValue: {
error: jest.fn(),
info: jest.fn(),
},
},
{
provide: Authentication,
useClass: jest.fn().mockImplementation(() => ({
canActivate: () => true,
})),
},
],
}).compile();
@ -49,17 +62,11 @@ describe('SurveyController', () => {
responseSchemaService = module.get<ResponseSchemaService>(
ResponseSchemaService,
);
contentSecurityService = module.get<ContentSecurityService>(
ContentSecurityService,
);
surveyHistoryService =
module.get<SurveyHistoryService>(SurveyHistoryService);
});
describe('getBannerData', () => {
it('should return banner data', async () => {
const result = await controller.getBannerData();
expect(result.code).toBe(200);
expect(result.data).toBeDefined();
});
@ -71,33 +78,16 @@ describe('SurveyController', () => {
surveyType: 'normal',
remark: '问卷调研',
title: '问卷调研',
} as SurveyMeta;
};
const newId = new ObjectId();
jest
.spyOn(surveyMetaService, 'createSurveyMeta')
.mockImplementation(() => {
const result = {
_id: newId,
} as SurveyMeta;
return Promise.resolve(result);
});
jest
.spyOn(surveyConfService, 'createSurveyConf')
.mockImplementation(
(params: {
surveyId: string;
surveyType: string;
createMethod: string;
createFrom: string;
}) => {
const result = {
_id: new ObjectId(),
pageId: params.surveyId,
code: {},
} as SurveyConf;
return Promise.resolve(result);
},
);
jest.spyOn(surveyMetaService, 'createSurveyMeta').mockResolvedValue({
_id: newId,
} as any);
jest.spyOn(surveyConfService, 'createSurveyConf').mockResolvedValue({
_id: new ObjectId(),
} as any);
const result = await controller.createSurvey(surveyInfo, {
user: { username: 'testUser', _id: new ObjectId() },
@ -111,13 +101,15 @@ describe('SurveyController', () => {
});
});
it('should throw an error if validation fails', async () => {
const surveyInfo = {}; // Invalid data
await expect(
controller.createSurvey(surveyInfo as any, { user: {} }),
).rejects.toThrow(HttpException);
});
it('should create a new survey by copy', async () => {
const existsSurveyId = new ObjectId();
const existsSurveyMeta = {
_id: existsSurveyId,
surveyType: 'exam',
owner: 'testUser',
} as SurveyMeta;
const params = {
surveyType: 'normal',
remark: '问卷调研',
@ -126,19 +118,15 @@ describe('SurveyController', () => {
createFrom: existsSurveyId.toString(),
};
jest
.spyOn(surveyMetaService, 'createSurveyMeta')
.mockImplementation(() => {
const result = {
_id: new ObjectId(),
} as SurveyMeta;
return Promise.resolve(result);
});
const request = {
user: { username: 'testUser', _id: new ObjectId() },
surveyMeta: existsSurveyMeta,
}; // 模拟请求对象,根据实际情况进行调整
surveyMeta: { _id: existsSurveyId, surveyType: 'exam' },
};
jest.spyOn(surveyMetaService, 'createSurveyMeta').mockResolvedValue({
_id: new ObjectId(),
} as any);
const result = await controller.createSurvey(params, request);
expect(result?.data?.id).toBeDefined();
});
@ -147,48 +135,30 @@ describe('SurveyController', () => {
describe('updateConf', () => {
it('should update survey configuration', async () => {
const surveyId = new ObjectId();
const surveyMeta = {
_id: surveyId,
surveyType: 'exam',
owner: 'testUser',
} as SurveyMeta;
jest
.spyOn(surveyConfService, 'saveSurveyConf')
.mockResolvedValue(undefined);
jest
.spyOn(surveyHistoryService, 'addHistory')
.mockResolvedValue(undefined);
const reqBody = {
surveyId: surveyId.toString(),
configData: {
bannerConf: {
titleConfig: {},
bannerConfig: {},
},
baseConf: {
begTime: '2024-01-23 21:59:05',
endTime: '2034-01-23 21:59:05',
},
bottomConf: { logoImage: '/imgs/Logo.webp', logoImageWidth: '60%' },
skinConf: { skinColor: '#4a4c5b', inputBgColor: '#ffffff' },
submitConf: {},
dataConf: {
dataList: [],
},
/* ... your config data here ... */
},
sessionId: 'mock-session-id',
};
const result = await controller.updateConf(reqBody, {
user: { username: 'testUser', _id: 'testUserId' },
surveyMeta,
surveyMeta: { _id: surveyId },
});
expect(result).toEqual({
code: 200,
});
});
it('should throw an error if validation fails', async () => {
const reqBody = {}; // Invalid data
await expect(
controller.updateConf(reqBody, { user: {} }),
).rejects.toThrow(HttpException);
});
});
describe('deleteSurvey', () => {
@ -198,7 +168,7 @@ describe('SurveyController', () => {
_id: surveyId,
surveyType: 'exam',
owner: 'testUser',
} as SurveyMeta;
};
jest
.spyOn(surveyMetaService, 'deleteSurveyMeta')
@ -208,13 +178,10 @@ describe('SurveyController', () => {
.mockResolvedValue(undefined);
const result = await controller.deleteSurvey({
user: { username: 'testUser' },
surveyMeta,
user: { username: 'testUser', _id: new ObjectId() },
});
expect(result).toEqual({
code: 200,
});
expect(result).toEqual({ code: 200 });
});
});
@ -225,115 +192,102 @@ describe('SurveyController', () => {
_id: surveyId,
surveyType: 'exam',
owner: 'testUser',
} as SurveyMeta;
};
jest
.spyOn(surveyConfService, 'getSurveyConfBySurveyId')
.mockResolvedValue(
Promise.resolve({
_id: new ObjectId(),
pageId: surveyId.toString(),
} as SurveyConf),
);
const request = {
user: { username: 'testUser', _id: new ObjectId() },
surveyMeta,
};
.mockResolvedValue({} as any);
const result = await controller.getSurvey(
{ surveyId: surveyId.toString() },
request,
{
surveyMeta,
user: { username: 'testUser', _id: new ObjectId() },
},
);
expect(result?.data?.surveyMetaRes).toBeDefined();
expect(result?.data?.surveyConfRes).toBeDefined();
});
});
describe('publishSurvey', () => {
it('should publish a survey success', async () => {
it('should publish a survey successfully', async () => {
const surveyId = new ObjectId();
const surveyMeta = {
_id: surveyId,
surveyType: 'exam',
owner: 'testUser',
} as SurveyMeta;
isDeleted: false,
};
jest
.spyOn(surveyConfService, 'getSurveyConfBySurveyId')
.mockResolvedValue(
Promise.resolve({
_id: new ObjectId(),
pageId: surveyId.toString(),
} as SurveyConf),
);
.mockResolvedValue({
code: {},
} as any);
jest
.spyOn(surveyConfService, 'getSurveyContentByCode')
.mockResolvedValue({
text: '题目1',
});
jest
.spyOn(contentSecurityService, 'isForbiddenContent')
.mockResolvedValue(false);
.mockResolvedValue({ text: '' });
jest
.spyOn(surveyMetaService, 'publishSurveyMeta')
.mockResolvedValue(undefined);
jest
.spyOn(responseSchemaService, 'publishResponseSchema')
.mockResolvedValue(undefined);
jest
.spyOn(surveyHistoryService, 'addHistory')
.mockResolvedValue(undefined);
const result = await controller.publishSurvey(
{ surveyId: surveyId.toString() },
{ user: { username: 'testUser', _id: 'testUserId' }, surveyMeta },
{ surveyMeta, user: { username: 'testUser', _id: new ObjectId() } },
);
expect(result).toEqual({
code: 200,
});
expect(result.code).toBe(200);
});
it('should not publish a survey with forbidden content', async () => {
it('should throw an error if the survey is deleted', async () => {
const surveyId = new ObjectId();
const surveyMeta = {
_id: surveyId,
surveyType: 'normal',
owner: 'testUser',
} as SurveyMeta;
jest
.spyOn(surveyConfService, 'getSurveyConfBySurveyId')
.mockResolvedValue(
Promise.resolve({
_id: new ObjectId(),
pageId: surveyId.toString(),
} as SurveyConf),
);
jest
.spyOn(surveyConfService, 'getSurveyContentByCode')
.mockResolvedValue({
text: '违禁词',
});
jest
.spyOn(contentSecurityService, 'isForbiddenContent')
.mockResolvedValue(true);
const surveyMeta = { _id: surveyId, isDeleted: true };
await expect(
controller.publishSurvey(
{ surveyId: surveyId.toString() },
{ user: { username: 'testUser', _id: 'testUserId' }, surveyMeta },
{ surveyMeta, user: { username: 'testUser' } },
),
).rejects.toThrow(
new HttpException(
'问卷存在非法关键字,不允许发布',
EXCEPTION_CODE.SURVEY_CONTENT_NOT_ALLOW,
),
);
).rejects.toThrow(HttpException);
});
});
// New tests for additional methods
describe('pausingSurvey', () => {
it('should pause the survey successfully', async () => {
const surveyMeta = { surveyPath: 'some/path' };
jest
.spyOn(surveyMetaService, 'pausingSurveyMeta')
.mockResolvedValue(undefined);
jest
.spyOn(responseSchemaService, 'pausingResponseSchema')
.mockResolvedValue(undefined);
const result = await controller.pausingSurvey({
surveyMeta,
user: { username: 'testUser' },
});
expect(result.code).toBe(200);
});
});
describe('getPreviewSchema', () => {
it('should get the preview schema successfully', async () => {
const surveyId = new ObjectId();
jest
.spyOn(surveyConfService, 'getSurveyConfBySurveyId')
.mockResolvedValue({} as any);
jest.spyOn(surveyMetaService, 'getSurveyById').mockResolvedValue({
title: 'Test Survey',
surveyPath: 'some/path',
} as any);
const result = await controller.getPreviewSchema({
surveyPath: surveyId.toString(),
});
expect(result.code).toBe(200);
});
});
});

View File

@ -0,0 +1,132 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyGroupController } from '../controllers/surveyGroup.controller';
import { SurveyGroupService } from '../services/surveyGroup.service';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { HttpException } from 'src/exceptions/httpException';
import { ObjectId } from 'mongodb';
import { Logger } from 'src/logger';
jest.mock('src/guards/authentication.guard');
describe('SurveyGroupController', () => {
let controller: SurveyGroupController;
let service: SurveyGroupService;
const mockService = {
create: jest.fn(),
findAll: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SurveyGroupController],
providers: [
{
provide: SurveyMetaService,
useValue: {
countSurveyMetaByGroupId: jest.fn().mockResolvedValue(0),
},
},
{
provide: SurveyGroupService,
useValue: mockService,
},
{
provide: Logger,
useValue: {
error: jest.fn(),
info: jest.fn(),
},
},
],
}).compile();
controller = module.get<SurveyGroupController>(SurveyGroupController);
service = module.get<SurveyGroupService>(SurveyGroupService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('create', () => {
it('should create a survey group', async () => {
const result = {
_id: new ObjectId(),
name: 'Test Group',
ownerId: '123',
createdAt: new Date(),
updatedAt: new Date(),
}; // 确保这里返回的对象结构符合预期
jest.spyOn(service, 'create').mockResolvedValue(result);
// 创建模拟的请求对象
const req = {
user: {
_id: '123', // 模拟的用户ID
},
};
expect(await controller.create({ name: 'Test Group' }, req)).toEqual({
code: 200,
data: {
id: result._id,
},
});
expect(service.create).toHaveBeenCalledWith({
name: 'Test Group',
ownerId: req.user._id.toString(), // 这里用模拟的 req.user._id
});
});
});
describe('findAll', () => {
it('should return a list of survey groups', async () => {
const result = { total: 0, notTotal: 0, list: [], allList: [] };
jest.spyOn(service, 'findAll').mockResolvedValue(result);
const mockReq = { user: { _id: new ObjectId() } };
const mockQue = { curPage: 1, pageSize: 10, name: '' };
const userId = mockReq.user._id.toString();
expect(await controller.findAll(mockReq, mockQue)).toEqual({
code: 200,
data: result,
});
expect(service.findAll).toHaveBeenCalledWith(userId, '', 0, 10);
});
});
describe('update', () => {
it('should update a survey group', async () => {
const updatedFields = { name: 'xxx' };
const updatedResult = { raw: 'xxx', generatedMaps: [] };
const id = '1';
jest.spyOn(service, 'update').mockResolvedValue(updatedResult);
expect(await controller.updateOne(id, updatedFields)).toEqual({
code: 200,
ret: updatedResult,
});
expect(service.update).toHaveBeenCalledWith(id, updatedFields);
});
it('should throw error on invalid parameter', async () => {
const id = '1';
const invalidFields: any = {};
await expect(controller.updateOne(id, invalidFields)).rejects.toThrow(
HttpException,
);
});
});
describe('remove', () => {
it('should remove a survey group', async () => {
const id = '1';
jest.spyOn(service, 'remove').mockResolvedValue(undefined);
expect(await controller.remove(id)).toEqual({ code: 200 });
expect(service.remove).toHaveBeenCalledWith(id);
});
});
});

View File

@ -0,0 +1,102 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyGroupService } from '../services/surveyGroup.service';
import { SurveyGroup } from 'src/models/surveyGroup.entity';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
describe('SurveyGroupService', () => {
let service: SurveyGroupService;
const mockSurveyGroupRepository = {
create: jest.fn(),
save: jest.fn(),
findAndCount: jest.fn(),
find: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
const mockSurveyMetaRepository = {
updateMany: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyGroupService,
{
provide: getRepositoryToken(SurveyGroup),
useValue: mockSurveyGroupRepository,
},
{
provide: getRepositoryToken(SurveyMeta),
useValue: mockSurveyMetaRepository,
},
],
}).compile();
service = module.get<SurveyGroupService>(SurveyGroupService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should create a survey group', async () => {
const createParams = { name: 'Test Group', ownerId: '123' };
const mockSavedGroup = { ...createParams, id: '1' };
mockSurveyGroupRepository.create.mockReturnValue(mockSavedGroup);
mockSurveyGroupRepository.save.mockResolvedValue(mockSavedGroup);
expect(await service.create(createParams)).toEqual(mockSavedGroup);
expect(mockSurveyGroupRepository.create).toHaveBeenCalledWith(
createParams,
);
expect(mockSurveyGroupRepository.save).toHaveBeenCalledWith(
mockSavedGroup,
);
});
});
describe('findAll', () => {
it('should return survey groups', async () => {
const list = [{ id: '1', name: 'Test Group', ownerId: '123' }];
const total = list.length;
mockSurveyGroupRepository.findAndCount.mockResolvedValue([list, total]);
mockSurveyGroupRepository.find.mockResolvedValue(list);
const result = await service.findAll('123', '', 0, 10);
expect(result).toEqual({ total, list, allList: list });
expect(mockSurveyGroupRepository.findAndCount).toHaveBeenCalled();
expect(mockSurveyGroupRepository.find).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update a survey group', async () => {
const id = '1';
const updatedFields = { name: 'Updated Test Group' };
await service.update(id, updatedFields);
expect(mockSurveyGroupRepository.update).toHaveBeenCalledWith(id, {
...updatedFields,
updatedAt: expect.any(Date),
});
});
});
describe('remove', () => {
it('should remove a survey group', async () => {
const id = '1';
await service.remove(id);
expect(mockSurveyMetaRepository.updateMany).toHaveBeenCalledWith(
{ groupId: id },
{ $set: { groupId: null } },
);
expect(mockSurveyGroupRepository.delete).toHaveBeenCalledWith(id);
});
});
});

View File

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

View File

@ -42,7 +42,7 @@ describe('SurveyHistoryService', () => {
msgContent: undefined,
},
baseConf: {
begTime: '',
beginTime: '',
endTime: '',
answerBegTime: '',
answerEndTime: '',
@ -78,7 +78,12 @@ describe('SurveyHistoryService', () => {
.spyOn(repository, 'save')
.mockResolvedValueOnce({} as SurveyHistory);
await service.addHistory({ surveyId, schema, type, user });
await service.addHistory({
surveyId,
schema,
type,
user,
});
expect(spyCreate).toHaveBeenCalledWith({
pageId: surveyId,
@ -116,9 +121,9 @@ describe('SurveyHistoryService', () => {
},
take: 100,
order: {
createDate: -1,
createdAt: -1,
},
select: ['createDate', 'operator', 'type', '_id'],
select: ['createdAt', 'operator', 'type', '_id'],
});
});
});

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SurveyMetaController } from '../controllers/surveyMeta.controller';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { LoggerProvider } from 'src/logger/logger.provider';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { CollaboratorService } from '../services/collaborator.service';
@ -28,7 +28,12 @@ describe('SurveyMetaController', () => {
.mockResolvedValue({ count: 0, data: [] }),
},
},
LoggerProvider,
{
provide: Logger,
useValue: {
error() {},
},
},
{
provide: CollaboratorService,
useValue: {
@ -54,18 +59,26 @@ describe('SurveyMetaController', () => {
remark: '',
};
const mockUser = {
username: 'test-user',
_id: new ObjectId(),
};
const req = {
user: {
username: 'test-user',
},
user: mockUser,
surveyMeta: survey,
};
const result = await controller.updateMeta(reqBody, req);
expect(surveyMetaService.editSurveyMeta).toHaveBeenCalledWith({
title: reqBody.title,
remark: reqBody.remark,
operator: mockUser.username,
operatorId: mockUser._id.toString(),
survey: {
title: reqBody.title,
remark: reqBody.remark,
groupId: null,
},
});
expect(result).toEqual({ code: 200 });
@ -111,11 +124,15 @@ describe('SurveyMetaController', () => {
data: [
{
_id: new ObjectId(),
createDate: date,
updateDate: date,
createdAt: date,
updatedAt: date,
curStatus: {
date: date,
},
subStatus: {
date: date,
},
surveyType: 'normal',
},
],
});
@ -129,10 +146,7 @@ describe('SurveyMetaController', () => {
count: 10,
data: expect.arrayContaining([
expect.objectContaining({
createDate: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
updateDate: expect.stringMatching(
createdAt: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
curStatus: expect.objectContaining({
@ -140,10 +154,17 @@ describe('SurveyMetaController', () => {
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
}),
subStatus: expect.objectContaining({
date: expect.stringMatching(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/,
),
}),
surveyType: 'normal',
}),
]),
},
});
expect(surveyMetaService.getSurveyMetaList).toHaveBeenCalledWith({
pageNum: queryInfo.curPage,
pageSize: queryInfo.pageSize,
@ -170,7 +191,7 @@ describe('SurveyMetaController', () => {
condition: [{ field: 'surveyType', value: 'normal' }],
},
]),
order: JSON.stringify([{ field: 'createDate', value: -1 }]),
order: JSON.stringify([{ field: 'createdAt', value: -1 }]),
};
const userId = new ObjectId().toString();
const req = {
@ -190,8 +211,28 @@ describe('SurveyMetaController', () => {
surveyIdList: [],
userId,
filter: { surveyType: 'normal', title: { $regex: 'hahah' } },
order: { createDate: -1 },
order: { createdAt: -1 },
workspaceId: undefined,
});
});
it('should handle Joi validation in getList', async () => {
const invalidQueryInfo: any = {
curPage: 'invalid',
pageSize: 10,
};
const req = {
user: {
username: 'test-user',
_id: new ObjectId(),
},
};
try {
await controller.getList(invalidQueryInfo, req);
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(error.code).toBe(EXCEPTION_CODE.PARAMETER_ERROR);
}
});
});

View File

@ -2,18 +2,16 @@ import { Test, TestingModule } from '@nestjs/testing';
import { SurveyMetaService } from '../services/surveyMeta.service';
import { MongoRepository } from 'typeorm';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { PluginManagerProvider } from 'src/securityPlugin/pluginManager.provider';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { RECORD_STATUS } from 'src/enums';
import { PluginManager } from 'src/securityPlugin/pluginManager';
import { getRepositoryToken } from '@nestjs/typeorm';
import { HttpException } from 'src/exceptions/httpException';
import { SurveyUtilPlugin } from 'src/securityPlugin/surveyUtilPlugin';
import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
describe('SurveyMetaService', () => {
let service: SurveyMetaService;
let surveyRepository: MongoRepository<SurveyMeta>;
let pluginManager: XiaojuSurveyPluginManager;
let pluginManager: PluginManager;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -26,10 +24,11 @@ describe('SurveyMetaService', () => {
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
updateOne: jest.fn(),
findAndCount: jest.fn(),
},
},
PluginManagerProvider,
PluginManager,
],
}).compile();
@ -37,21 +36,19 @@ describe('SurveyMetaService', () => {
surveyRepository = module.get<MongoRepository<SurveyMeta>>(
getRepositoryToken(SurveyMeta),
);
pluginManager = module.get<XiaojuSurveyPluginManager>(
XiaojuSurveyPluginManager,
);
pluginManager.registerPlugin(new SurveyUtilPlugin());
pluginManager = module.get<PluginManager>(PluginManager);
});
describe('getNewSurveyPath', () => {
it('should generate a new survey path', async () => {
jest.spyOn(surveyRepository, 'count').mockResolvedValueOnce(1);
jest.spyOn(pluginManager, 'triggerHook').mockResolvedValueOnce('path1');
jest.spyOn(surveyRepository, 'count').mockResolvedValueOnce(0);
const surveyPath = await service.getNewSurveyPath();
expect(typeof surveyPath).toBe('string');
expect(surveyRepository.count).toHaveBeenCalledTimes(2);
expect(surveyPath).toBe('path1');
expect(pluginManager.triggerHook).toHaveBeenCalledTimes(1);
expect(surveyRepository.count).toHaveBeenCalledTimes(1);
});
});
@ -65,14 +62,11 @@ describe('SurveyMetaService', () => {
userId: new ObjectId().toString(),
createMethod: '',
createFrom: '',
workspaceId: 'workspace1',
};
const newSurvey = new SurveyMeta();
const mockedSurveyPath = 'mockedSurveyPath';
jest
.spyOn(service, 'getNewSurveyPath')
.mockResolvedValue(mockedSurveyPath);
jest.spyOn(service, 'getNewSurveyPath').mockResolvedValue('path1');
jest
.spyOn(surveyRepository, 'create')
.mockImplementation(() => newSurvey);
@ -84,86 +78,119 @@ describe('SurveyMetaService', () => {
title: params.title,
remark: params.remark,
surveyType: params.surveyType,
surveyPath: mockedSurveyPath,
surveyPath: 'path1',
creator: params.username,
ownerId: params.userId,
creatorId: params.userId,
owner: params.username,
ownerId: params.userId,
createMethod: params.createMethod,
createFrom: params.createFrom,
workspaceId: params.workspaceId,
groupId: null,
});
expect(surveyRepository.save).toHaveBeenCalledWith(newSurvey);
expect(result).toEqual(newSurvey);
});
});
describe('editSurveyMeta', () => {
it('should edit a survey meta and return it if in NEW or EDITING status', async () => {
describe('pausingSurveyMeta', () => {
it('should throw an exception if survey is in NEW status', async () => {
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.NEW, date: Date.now() };
await expect(service.pausingSurveyMeta(survey)).rejects.toThrow(
HttpException,
);
});
it('should pause a survey and update subStatus', async () => {
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.PUBLISHED, date: Date.now() };
survey.statusList = [];
jest.spyOn(surveyRepository, 'save').mockResolvedValue(survey);
const result = await service.editSurveyMeta(survey);
const result = await service.pausingSurveyMeta(survey);
expect(survey.curStatus.status).toEqual(RECORD_STATUS.EDITING);
expect(survey.subStatus.status).toBe(RECORD_SUB_STATUS.PAUSING);
expect(survey.statusList.length).toBe(1);
expect(survey.statusList[0].status).toEqual(RECORD_STATUS.EDITING);
expect(survey.statusList[0].status).toBe(RECORD_SUB_STATUS.PAUSING);
expect(surveyRepository.save).toHaveBeenCalledWith(survey);
expect(result).toEqual(survey);
});
});
describe('editSurveyMeta', () => {
it('should edit a survey meta and return it', async () => {
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.PUBLISHED, date: Date.now() };
survey.statusList = [];
const operator = 'editor';
const operatorId = 'editorId';
jest.spyOn(surveyRepository, 'save').mockResolvedValue(survey);
const result = await service.editSurveyMeta({
survey,
operator,
operatorId,
});
expect(survey.curStatus.status).toBe(RECORD_STATUS.EDITING);
expect(survey.statusList.length).toBe(1);
expect(survey.statusList[0].status).toBe(RECORD_STATUS.EDITING);
expect(survey.operator).toBe(operator);
expect(survey.operatorId).toBe(operatorId);
expect(surveyRepository.save).toHaveBeenCalledWith(survey);
expect(result).toEqual(survey);
});
});
describe('deleteSurveyMeta', () => {
it('should delete survey meta and update status', async () => {
// 准备假的SurveyMeta对象
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.NEW, date: Date.now() };
survey.statusList = [];
it('should mark a survey as deleted', async () => {
const surveyId = new ObjectId().toString();
const operator = 'deleter';
const operatorId = 'deleterId';
// 模拟save方法
jest.spyOn(surveyRepository, 'save').mockResolvedValue(survey);
jest.spyOn(surveyRepository, 'updateOne').mockResolvedValue({
matchedCount: 1,
modifiedCount: 1,
acknowledged: true,
});
// 调用要测试的方法
const result = await service.deleteSurveyMeta(survey);
const result = await service.deleteSurveyMeta({
surveyId,
operator,
operatorId,
});
// 验证结果
expect(result).toBe(survey);
expect(survey.curStatus.status).toBe(RECORD_STATUS.REMOVED);
expect(survey.statusList.length).toBe(1);
expect(survey.statusList[0].status).toBe(RECORD_STATUS.REMOVED);
expect(surveyRepository.save).toHaveBeenCalledTimes(1);
expect(surveyRepository.save).toHaveBeenCalledWith(survey);
});
it('should throw exception when survey is already removed', async () => {
// 准备假的SurveyMeta对象其状态已设置为REMOVED
const survey = new SurveyMeta();
survey.curStatus = { status: RECORD_STATUS.REMOVED, date: Date.now() };
// 调用要测试的方法并期待异常
await expect(service.deleteSurveyMeta(survey)).rejects.toThrow(
HttpException,
expect(surveyRepository.updateOne).toHaveBeenCalledWith(
{ _id: new ObjectId(surveyId) },
{
$set: {
isDeleted: true,
operator,
operatorId,
deletedAt: expect.any(Date),
},
},
);
// 验证save方法没有被调用
expect(surveyRepository.save).not.toHaveBeenCalled();
expect(result.matchedCount).toBe(1);
});
});
describe('getSurveyMetaList', () => {
it('should return a list of survey metadata', async () => {
// 准备模拟数据
const mockData = [
{ _id: 1, title: 'Survey 1' },
{ _id: 2, title: 'Survey 2' },
] as unknown as Array<SurveyMeta>;
const mockCount = 2;
const mockCount = 1;
jest
.spyOn(surveyRepository, 'findAndCount')
.mockResolvedValue([mockData, mockCount]);
// 调用方法并检查返回值
const condition = {
pageNum: 1,
pageSize: 10,
@ -172,40 +199,47 @@ describe('SurveyMetaService', () => {
filter: {},
order: {},
};
const result = await service.getSurveyMetaList(condition);
// 验证返回值
expect(result).toEqual({ data: mockData, count: mockCount });
// 验证repository方法被正确调用
expect(surveyRepository.findAndCount).toHaveBeenCalledTimes(1);
});
});
describe('publishSurveyMeta', () => {
it('should publish a survey meta and add status to statusList', async () => {
// 准备模拟数据
const surveyMeta = {
id: 1,
title: 'Test Survey',
statusList: [],
} as unknown as SurveyMeta;
const savedSurveyMeta = {
...surveyMeta,
curStatus: {
status: RECORD_STATUS.PUBLISHED,
date: expect.any(Number),
},
} as unknown as SurveyMeta;
it('should publish a survey and update curStatus', async () => {
const surveyMeta = new SurveyMeta();
surveyMeta.statusList = [];
jest.spyOn(surveyRepository, 'save').mockResolvedValue(savedSurveyMeta);
jest.spyOn(surveyRepository, 'save').mockResolvedValue(surveyMeta);
// 调用方法并检查返回值
const result = await service.publishSurveyMeta({ surveyMeta });
// 验证返回值
expect(result).toEqual(savedSurveyMeta);
// 验证repository方法被正确调用
expect(surveyRepository.save).toHaveBeenCalledWith(savedSurveyMeta);
expect(surveyMeta.curStatus.status).toBe(RECORD_STATUS.PUBLISHED);
expect(surveyMeta.statusList.length).toBe(1);
expect(surveyMeta.statusList[0].status).toBe(RECORD_STATUS.PUBLISHED);
expect(surveyRepository.save).toHaveBeenCalledWith(surveyMeta);
expect(result).toEqual(surveyMeta);
});
});
describe('countSurveyMetaByWorkspaceId', () => {
it('should return the count of surveys in a workspace', async () => {
const workspaceId = 'workspace1';
const mockCount = 5;
jest.spyOn(surveyRepository, 'count').mockResolvedValue(mockCount);
const result = await service.countSurveyMetaByWorkspaceId({
workspaceId,
});
expect(result).toBe(mockCount);
expect(surveyRepository.count).toHaveBeenCalledWith({
workspaceId,
isDeleted: { $ne: true },
});
});
});
});

View File

@ -69,7 +69,7 @@ export class CollaboratorController {
) {
const { error, value } = CreateCollaboratorDto.validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException(
'系统错误,请联系管理员',
EXCEPTION_CODE.PARAMETER_ERROR,
@ -124,7 +124,7 @@ export class CollaboratorController {
) {
const { error, value } = BatchSaveCollaboratorDto.validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException(
'系统错误,请联系管理员',
EXCEPTION_CODE.PARAMETER_ERROR,
@ -184,11 +184,15 @@ export class CollaboratorController {
neIdList: collaboratorIdList,
userIdList: newCollaboratorUserIdList,
});
this.logger.info('batchDelete:' + JSON.stringify(delRes), { req });
this.logger.info('batchDelete:' + JSON.stringify(delRes));
const username = req.user.username;
const userId = req.user._id.toString();
if (Array.isArray(newCollaborator) && newCollaborator.length > 0) {
const insertRes = await this.collaboratorService.batchCreate({
surveyId: value.surveyId,
collaboratorList: newCollaborator,
creator: username,
creatorId: userId,
});
this.logger.info(`${JSON.stringify(insertRes)}`);
}
@ -198,6 +202,8 @@ export class CollaboratorController {
this.collaboratorService.updateById({
collaboratorId: item._id,
permissions: item.permissions,
operator: username,
operatorId: userId,
}),
),
);
@ -208,7 +214,7 @@ export class CollaboratorController {
const delRes = await this.collaboratorService.batchDeleteBySurveyId(
value.surveyId,
);
this.logger.info(JSON.stringify(delRes), { req });
this.logger.info(JSON.stringify(delRes));
}
return {
@ -225,11 +231,10 @@ export class CollaboratorController {
])
async getSurveyCollaboratorList(
@Query() query: GetSurveyCollaboratorListDto,
@Request() req,
) {
const { error, value } = GetSurveyCollaboratorListDto.validate(query);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -263,17 +268,14 @@ export class CollaboratorController {
@SetMetadata('surveyPermission', [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
])
async changeUserPermission(
@Body() reqBody: ChangeUserPermissionDto,
@Request() req,
) {
async changeUserPermission(@Body() reqBody: ChangeUserPermissionDto) {
const { error, value } = Joi.object({
surveyId: Joi.string(),
userId: Joi.string(),
permissions: Joi.array().items(Joi.string().required()),
}).validate(reqBody);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -292,13 +294,13 @@ export class CollaboratorController {
@SetMetadata('surveyPermission', [
SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
])
async deleteCollaborator(@Query() query, @Request() req) {
async deleteCollaborator(@Query() query) {
const { error, value } = Joi.object({
surveyId: Joi.string(),
userId: Joi.string(),
}).validate(query);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
@ -319,7 +321,7 @@ export class CollaboratorController {
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) {
this.logger.error(`问卷不存在: ${surveyId}`, { req });
this.logger.error(`问卷不存在: ${surveyId}`);
throw new HttpException('问卷不存在', EXCEPTION_CODE.SURVEY_NOT_FOUND);
}

View File

@ -5,7 +5,6 @@ import {
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
@ -14,7 +13,7 @@ import { DataStatisticService } from '../services/dataStatistic.service';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { Authentication } from 'src/guards/authentication.guard';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { PluginManager } from 'src/securityPlugin/pluginManager';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger';
@ -22,6 +21,7 @@ import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { AggregationStatisDto } from '../dto/aggregationStatis.dto';
import { handleAggretionData } from '../utils';
import { QUESTION_TYPE } from 'src/enums/question';
@ApiTags('survey')
@ApiBearerAuth()
@ -30,7 +30,7 @@ export class DataStatisticController {
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly dataStatisticService: DataStatisticService,
private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly pluginManager: PluginManager,
private readonly logger: Logger,
) {}
@ -43,19 +43,18 @@ export class DataStatisticController {
async data(
@Query()
queryInfo,
@Request() req,
) {
const { value, error } = await Joi.object({
surveyId: Joi.string().required(),
isDesensitive: Joi.boolean().default(true), // 默认true就是需要脱敏
isMasked: Joi.boolean().default(true), // 默认true就是需要脱敏
page: Joi.number().default(1),
pageSize: Joi.number().default(10),
}).validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { surveyId, isDesensitive, page, pageSize } = value;
const { surveyId, isMasked, page, pageSize } = value;
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const { total, listHead, listBody } =
@ -66,10 +65,10 @@ export class DataStatisticController {
pageSize,
});
if (isDesensitive) {
if (isMasked) {
// 脱敏
listBody.forEach((item) => {
this.pluginManager.triggerHook('desensitiveData', item);
this.pluginManager.triggerHook('maskData', item);
});
}
@ -103,15 +102,15 @@ export class DataStatisticController {
};
}
const allowQuestionType = [
'radio',
'checkbox',
'binary-choice',
'radio-star',
'radio-nps',
'vote',
QUESTION_TYPE.RADIO,
QUESTION_TYPE.CHECKBOX,
QUESTION_TYPE.BINARY_CHOICE,
QUESTION_TYPE.RADIO_STAR,
QUESTION_TYPE.RADIO_NPS,
QUESTION_TYPE.VOTE,
];
const fieldList = responseSchema.code.dataConf.dataList
.filter((item) => allowQuestionType.includes(item.type))
.filter((item) => allowQuestionType.includes(item.type as QUESTION_TYPE))
.map((item) => item.field);
const dataMap = responseSchema.code.dataConf.dataList.reduce((pre, cur) => {
pre[cur.field] = cur;

View File

@ -0,0 +1,189 @@
import {
Controller,
Get,
Query,
HttpCode,
UseGuards,
SetMetadata,
Request,
Post,
Body,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ResponseSchemaService } from '../../surveyResponse/services/responseScheme.service';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { DownloadTaskService } from '../services/downloadTask.service';
import {
GetDownloadTaskDto,
CreateDownloadDto,
GetDownloadTaskListDto,
DeleteDownloadTaskDto,
} from '../dto/downloadTask.dto';
import moment from 'moment';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
@ApiTags('downloadTask')
@ApiBearerAuth()
@Controller('/api/downloadTask')
export class DownloadTaskController {
constructor(
private readonly responseSchemaService: ResponseSchemaService,
private readonly downloadTaskService: DownloadTaskService,
private readonly logger: Logger,
) {}
@Post('/createTask')
@HttpCode(200)
@UseGuards(SurveyGuard)
@SetMetadata('surveyId', 'body.surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE])
@UseGuards(Authentication)
async createTask(
@Body()
reqBody: CreateDownloadDto,
@Request() req,
) {
const { value, error } = CreateDownloadDto.validate(reqBody);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { surveyId, isMasked } = value;
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const id = await this.downloadTaskService.createDownloadTask({
surveyId,
responseSchema,
creatorId: req.user._id.toString(),
creator: req.user.username,
params: { isMasked },
});
this.downloadTaskService.processDownloadTask({ taskId: id });
return {
code: 200,
data: { taskId: id },
};
}
@Get('/getDownloadTaskList')
@HttpCode(200)
@UseGuards(Authentication)
async downloadList(
@Query()
queryInfo: GetDownloadTaskListDto,
@Request() req,
) {
const { value, error } = GetDownloadTaskListDto.validate(queryInfo);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { pageIndex, pageSize } = value;
const { total, list } = await this.downloadTaskService.getDownloadTaskList({
creatorId: req.user._id.toString(),
pageIndex,
pageSize,
});
return {
code: 200,
data: {
total: total,
list: list.map((data) => {
const item: Record<string, any> = {};
item.taskId = data._id.toString();
item.status = data.status;
item.filename = data.filename;
item.url = data.url;
const fmt = 'YYYY-MM-DD HH:mm:ss';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let size = Number(data.fileSize);
if (isNaN(size)) {
item.fileSize = data.fileSize;
} else {
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
item.fileSize = `${size.toFixed()} ${units[unitIndex]}`;
}
item.createdAt = moment(data.createdAt).format(fmt);
return item;
}),
},
};
}
@Get('/getDownloadTask')
@HttpCode(200)
@UseGuards(Authentication)
async getDownloadTask(@Query() query: GetDownloadTaskDto, @Request() req) {
const { value, error } = GetDownloadTaskDto.validate(query);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const taskInfo = await this.downloadTaskService.getDownloadTaskById({
taskId: value.taskId,
});
if (!taskInfo) {
throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR);
}
if (taskInfo.creatorId !== req.user._id.toString()) {
throw new NoPermissionException('没有权限');
}
const res: Record<string, any> = {
...taskInfo,
};
res.taskId = taskInfo._id.toString();
delete res._id;
return {
code: 200,
data: res,
};
}
@Post('/deleteDownloadTask')
@HttpCode(200)
@UseGuards(Authentication)
async deleteFileByName(@Body() body: DeleteDownloadTaskDto, @Request() req) {
const { value, error } = DeleteDownloadTaskDto.validate(body);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const { taskId } = value;
const taskInfo = await this.downloadTaskService.getDownloadTaskById({
taskId,
});
if (!taskInfo) {
throw new HttpException('任务不存在', EXCEPTION_CODE.PARAMETER_ERROR);
}
if (taskInfo.creatorId !== req.user._id.toString()) {
throw new NoPermissionException('没有权限');
}
const delRes = await this.downloadTaskService.deleteDownloadTask({
taskId,
operator: req.user.username,
operatorId: req.user._id.toString(),
});
return {
code: 200,
data: delRes.modifiedCount === 1,
};
}
}

View File

@ -0,0 +1,90 @@
import {
Controller,
Post,
Body,
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags } from '@nestjs/swagger';
import { SessionService } from '../services/session.service';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyGuard } from 'src/guards/survey.guard';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { SessionGuard } from 'src/guards/session.guard';
@ApiTags('survey')
@Controller('/api/session')
export class SessionController {
constructor(
private readonly sessionService: SessionService,
private readonly logger: Logger,
) {}
@Post('/create')
@HttpCode(200)
@UseGuards(SurveyGuard)
@SetMetadata('surveyId', 'body.surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE])
@UseGuards(Authentication)
async create(
@Body()
reqBody: {
surveyId: string;
},
@Request()
req,
) {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
}).validate(reqBody);
if (error) {
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const surveyId = value.surveyId;
const session = await this.sessionService.create({
surveyId,
userId: req.user._id.toString(),
});
return {
code: 200,
data: {
sessionId: session._id.toString(),
},
};
}
@Post('/seize')
@HttpCode(200)
@UseGuards(SessionGuard, SurveyGuard)
@SetMetadata('sessionId', 'body.sessionId')
@SetMetadata('surveyId', 'surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE])
@UseGuards(Authentication)
async seize(
@Request()
req,
) {
const sessionInfo = req.sessionInfo;
await this.sessionService.updateSessionToEditing({
sessionId: sessionInfo._id.toString(),
surveyId: sessionInfo.surveyId,
});
return {
code: 200,
};
}
}

View File

@ -31,6 +31,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { WorkspaceGuard } from 'src/guards/workspace.guard';
import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace';
import { SessionService } from '../services/session.service';
import { UserService } from 'src/modules/auth/services/user.service';
@ApiTags('survey')
@Controller('/api/survey')
@ -42,6 +44,8 @@ export class SurveyController {
private readonly contentSecurityService: ContentSecurityService,
private readonly surveyHistoryService: SurveyHistoryService,
private readonly logger: Logger,
private readonly sessionService: SessionService,
private readonly userService: UserService,
) {}
@Get('/getBannerData')
@ -70,13 +74,11 @@ 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);
}
const { title, remark, createMethod, createFrom } = value;
const { title, remark, createMethod, createFrom, groupId } = value;
let surveyType = '',
workspaceId = null;
@ -98,6 +100,7 @@ export class SurveyController {
createMethod,
createFrom,
workspaceId,
groupId,
});
await this.surveyConfService.createSurveyConf({
surveyId: surveyMeta._id.toString(),
@ -128,13 +131,41 @@ export class SurveyController {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
configData: Joi.any().required(),
sessionId: Joi.string().required(),
}).validate(surveyInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const username = req.user.username;
const sessionId = value.sessionId;
const surveyId = value.surveyId;
const latestEditingOne = await this.sessionService.findLatestEditingOne({
surveyId,
});
if (latestEditingOne && latestEditingOne._id.toString() !== sessionId) {
const curSession = await this.sessionService.findOne(sessionId);
if (curSession.createdAt <= latestEditingOne.updatedAt) {
// 在当前用户打开之后,被其他页面保存过了
const isSameOperator =
latestEditingOne.userId === req.user._id.toString();
let preOperator;
if (!isSameOperator) {
preOperator = await this.userService.getUserById(
latestEditingOne.userId,
);
}
return {
code: EXCEPTION_CODE.SURVEY_SAVE_CONFLICT,
errmsg: isSameOperator
? '当前问卷已在其它页面开启编辑,刷新以获取最新内容'
: `当前问卷已由 ${preOperator.username} 编辑,刷新以获取最新内容`,
};
}
}
await this.sessionService.updateSessionToEditing({ sessionId, surveyId });
const username = req.user.username;
const configData = value.configData;
await this.surveyConfService.saveSurveyConf({
@ -164,8 +195,35 @@ export class SurveyController {
async deleteSurvey(@Request() req) {
const surveyMeta = req.surveyMeta;
await this.surveyMetaService.deleteSurveyMeta(surveyMeta);
await this.responseSchemaService.deleteResponseSchema({
const delMetaRes = await this.surveyMetaService.deleteSurveyMeta({
surveyId: surveyMeta._id.toString(),
operator: req.user.username,
operatorId: req.user._id.toString(),
});
const delResponseRes =
await this.responseSchemaService.deleteResponseSchema({
surveyPath: surveyMeta.surveyPath,
});
this.logger.info(JSON.stringify(delMetaRes));
this.logger.info(JSON.stringify(delResponseRes));
return {
code: 200,
};
}
@HttpCode(200)
@Post('/pausingSurvey')
@UseGuards(SurveyGuard)
@SetMetadata('surveyId', 'body.surveyId')
@SetMetadata('surveyPermission', [SURVEY_PERMISSION.SURVEY_CONF_MANAGE])
@UseGuards(Authentication)
async pausingSurvey(@Request() req) {
const surveyMeta = req.surveyMeta;
await this.surveyMetaService.pausingSurveyMeta(surveyMeta);
await this.responseSchemaService.pausingResponseSchema({
surveyPath: surveyMeta.surveyPath,
});
@ -197,7 +255,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);
}
@ -230,15 +288,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;
@ -271,12 +327,18 @@ export class SurveyController {
surveyId: Joi.string().required(),
}).validate(surveyInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const username = req.user.username;
const surveyId = value.surveyId;
const surveyMeta = req.surveyMeta;
if (surveyMeta.isDeleted) {
throw new HttpException(
'问卷已删除,无法发布',
EXCEPTION_CODE.SURVEY_NOT_FOUND,
);
}
const surveyConf =
await this.surveyConfService.getSurveyConfBySurveyId(surveyId);
@ -302,7 +364,8 @@ export class SurveyController {
pageId: surveyId,
});
await this.surveyHistoryService.addHistory({
// 添加发布历史可以异步添加
this.surveyHistoryService.addHistory({
surveyId,
schema: surveyConf.code,
type: HISTORY_TYPE.PUBLISH_HIS,

View File

@ -0,0 +1,145 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
Request,
HttpCode,
Query,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import moment from 'moment';
import { Authentication } from 'src/guards/authentication.guard';
import { SurveyMetaService } from 'src/modules/survey/services/surveyMeta.service';
import { SurveyGroupService } from '../services/surveyGroup.service';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { CreateSurveyGroupDto } from '../dto/createSurveyGroup.dto';
import { UpdateSurveyGroupDto } from '../dto/updateSurveyGroup.dto';
import { GetGroupListDto } from '../dto/getGroupList.dto';
@ApiTags('surveyGroup')
@ApiBearerAuth()
@UseGuards(Authentication)
@Controller('api/surveyGroup')
export class SurveyGroupController {
constructor(
private readonly surveyMetaService: SurveyMetaService,
private readonly SurveyGroupService: SurveyGroupService,
private readonly logger: Logger,
) {}
@Post()
@HttpCode(200)
async create(
@Body()
reqBody: CreateSurveyGroupDto,
@Request()
req,
) {
const { error, value } = CreateSurveyGroupDto.validate(reqBody);
if (error) {
this.logger.error(`createSurveyGroup_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const userId = req.user._id.toString();
const ret = await this.SurveyGroupService.create({
name: value.name,
ownerId: userId,
});
return {
code: 200,
data: {
id: ret._id,
},
};
}
@Get()
@HttpCode(200)
async findAll(@Request() req, @Query() queryInfo: GetGroupListDto) {
const { value, error } = GetGroupListDto.validate(queryInfo);
if (error) {
this.logger.error(`GetGroupListDto validate failed: ${error.message}`);
throw new HttpException(
`参数错误: 请联系管理员`,
EXCEPTION_CODE.PARAMETER_ERROR,
);
}
const userId = req.user._id.toString();
const curPage = Number(value.curPage);
const pageSize = Number(value.pageSize);
const skip = (curPage - 1) * pageSize;
const { total, list, allList } = await this.SurveyGroupService.findAll(
userId,
value.name,
skip,
pageSize,
);
const groupIdList = list.map((item) => item._id.toString());
const surveyTotalList = await Promise.all(
groupIdList.map((item) => {
return this.surveyMetaService.countSurveyMetaByGroupId({
groupId: item,
});
}),
);
const surveyTotalMap = groupIdList.reduce((pre, cur, index) => {
const total = surveyTotalList[index];
pre[cur] = total;
return pre;
}, {});
const notTotal = await this.surveyMetaService.countSurveyMetaByGroupId({
groupId: null,
});
return {
code: 200,
data: {
total,
list: list.map((item) => {
const id = item._id.toString();
return {
...item,
createdAt: moment(item.createdAt).format('YYYY-MM-DD HH:mm:ss'),
surveyTotal: surveyTotalMap[id] || 0,
};
}),
allList,
notTotal,
},
};
}
@Post(':id')
@HttpCode(200)
async updateOne(
@Param('id') id: string,
@Body()
reqBody: UpdateSurveyGroupDto,
) {
const { error, value } = UpdateSurveyGroupDto.validate(reqBody);
if (error) {
this.logger.error(`createSurveyGroup_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const ret = await this.SurveyGroupService.update(id, value);
return {
code: 200,
ret,
};
}
@Delete(':id')
@HttpCode(200)
async remove(@Param('id') id: string) {
await this.SurveyGroupService.remove(id);
return {
code: 200,
};
}
}

View File

@ -5,7 +5,6 @@ import {
HttpCode,
UseGuards,
SetMetadata,
Request,
} from '@nestjs/common';
import * as Joi from 'joi';
import { ApiTags } from '@nestjs/swagger';
@ -18,9 +17,8 @@ import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
import { Logger } from 'src/logger';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
@ApiTags('survey')
@Controller('/api/surveyHisotry')
@Controller('/api/surveyHistory')
export class SurveyHistoryController {
constructor(
private readonly surveyHistoryService: SurveyHistoryService,
@ -43,7 +41,6 @@ export class SurveyHistoryController {
surveyId: string;
historyType: string;
},
@Request() req,
) {
const { value, error } = Joi.object({
surveyId: Joi.string().required(),
@ -51,7 +48,7 @@ export class SurveyHistoryController {
}).validate(queryInfo);
if (error) {
this.logger.error(error.message, { req });
this.logger.error(error.message);
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}

View File

@ -48,19 +48,24 @@ export class SurveyMetaController {
title: Joi.string().required(),
remark: Joi.string().allow(null, '').default(''),
surveyId: Joi.string().required(),
groupId: Joi.string().allow(null, ''),
}).validate(reqBody, { allowUnknown: true });
if (error) {
this.logger.error(`updateMeta_parameter error: ${error.message}`, {
req,
});
this.logger.error(`updateMeta_parameter error: ${error.message}`);
throw new HttpException('参数错误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const survey = req.surveyMeta;
survey.title = value.title;
survey.remark = value.remark;
survey.groupId =
value.groupId && value.groupId !== '' ? value.groupId : null;
await this.surveyMetaService.editSurveyMeta(survey);
await this.surveyMetaService.editSurveyMeta({
survey,
operator: req.user.username,
operatorId: req.user._id.toString(),
});
return {
code: 200,
@ -81,24 +86,24 @@ 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;
const { curPage, pageSize, workspaceId, groupId } = value;
let filter = {},
order = {};
if (value.filter) {
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();
@ -118,6 +123,7 @@ export class SurveyMetaController {
filter,
order,
workspaceId,
groupId,
surveyIdList,
});
return {
@ -129,9 +135,10 @@ export class SurveyMetaController {
if (!item.surveyType) {
item.surveyType = item.questionType || 'normal';
}
item.createDate = moment(item.createDate).format(fmt);
item.updateDate = moment(item.updateDate).format(fmt);
item.createdAt = moment(item.createdAt).format(fmt);
item.curStatus.date = moment(item.curStatus.date).format(fmt);
item.subStatus.date = moment(item.subStatus.date).format(fmt);
item.updatedAt = moment(item.updatedAt).format(fmt);
const surveyId = item._id.toString();
if (cooperSurveyIdMap[surveyId]) {
item.isCollaborated = true;

View File

@ -12,14 +12,17 @@ export class CreateSurveyDto {
surveyType: string;
@ApiProperty({ description: '创建方法', required: false })
createMethod: string;
createMethod?: string;
@ApiProperty({ description: '创建来源', required: false })
createFrom: string;
createFrom?: string;
@ApiProperty({ description: '问卷创建在哪个空间下', required: false })
workspaceId?: string;
@ApiProperty({ description: '问卷创建在哪个分组下', required: false })
groupId?: string;
static validate(data) {
return Joi.object({
title: Joi.string().required(),
@ -36,6 +39,7 @@ export class CreateSurveyDto {
otherwise: Joi.allow(null),
}),
workspaceId: Joi.string().allow(null, ''),
groupId: Joi.string().allow(null, ''),
}).validate(data);
}
}

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class CreateSurveyGroupDto {
@ApiProperty({ description: '分组名称', required: true })
name: string;
static validate(data) {
return Joi.object({
name: Joi.string().required(),
}).validate(data);
}
}

View File

@ -0,0 +1,51 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class CreateDownloadDto {
@ApiProperty({ description: '问卷id', required: true })
surveyId: string;
@ApiProperty({ description: '是否脱敏', required: false })
isMasked: boolean;
static validate(data) {
return Joi.object({
surveyId: Joi.string().required(),
isMasked: Joi.boolean().allow(null).default(false),
}).validate(data);
}
}
export class GetDownloadTaskListDto {
@ApiProperty({ description: '当前页', required: false })
pageIndex: number;
@ApiProperty({ description: '一页大小', required: false })
pageSize: number;
static validate(data) {
return Joi.object({
pageIndex: Joi.number().default(1),
pageSize: Joi.number().default(20),
}).validate(data);
}
}
export class GetDownloadTaskDto {
@ApiProperty({ description: '任务id', required: true })
taskId: string;
static validate(data) {
return Joi.object({
taskId: Joi.string().required(),
}).validate(data);
}
}
export class DeleteDownloadTaskDto {
@ApiProperty({ description: '任务id', required: true })
taskId: string;
static validate(data) {
return Joi.object({
taskId: Joi.string().required(),
}).validate(data);
}
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class GetGroupListDto {
@ApiProperty({ description: '当前页码', required: true })
curPage: number;
@ApiProperty({ description: '分页', required: false })
pageSize: number;
@ApiProperty({ description: '空间名称', required: false })
name?: string;
static validate(data: Partial<GetGroupListDto>): Joi.ValidationResult {
return Joi.object({
curPage: Joi.number().required(),
pageSize: Joi.number().allow(null).default(10),
name: Joi.string().allow(null, '').optional(),
}).validate(data);
}
}

View File

@ -17,6 +17,9 @@ export class GetSurveyListDto {
@ApiProperty({ description: '空间id', required: false })
workspaceId?: string;
@ApiProperty({ description: '分组id', required: false })
groupId?: string;
static validate(data) {
return Joi.object({
curPage: Joi.number().required(),
@ -24,6 +27,7 @@ export class GetSurveyListDto {
filter: Joi.string().allow(null),
order: Joi.string().allow(null),
workspaceId: Joi.string().allow(null, ''),
groupId: Joi.string().allow(null, ''),
}).validate(data);
}
}

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class UpdateSurveyGroupDto {
@ApiProperty({ description: '分组名称', required: true })
name: string;
static validate(data) {
return Joi.object({
name: Joi.string().required(),
}).validate(data);
}
}

View File

@ -22,12 +22,17 @@ export class CollaboratorService {
return this.collaboratorRepository.save(collaborator);
}
async batchCreate({ surveyId, collaboratorList }) {
async batchCreate({ surveyId, collaboratorList, creator, creatorId }) {
const now = new Date();
const res = await this.collaboratorRepository.insertMany(
collaboratorList.map((item) => {
return {
...item,
surveyId,
createdAt: now,
updatedAt: now,
creator,
creatorId,
};
}),
);
@ -60,7 +65,13 @@ export class CollaboratorService {
return info;
}
async changeUserPermission({ userId, surveyId, permission }) {
async changeUserPermission({
userId,
surveyId,
permission,
operator,
operatorId,
}) {
const updateRes = await this.collaboratorRepository.updateOne(
{
surveyId,
@ -69,6 +80,9 @@ export class CollaboratorService {
{
$set: {
permission,
operator,
operatorId,
updatedAt: new Date(),
},
},
);
@ -134,7 +148,7 @@ export class CollaboratorService {
return delRes;
}
updateById({ collaboratorId, permissions }) {
updateById({ collaboratorId, permissions, operator, operatorId }) {
return this.collaboratorRepository.updateOne(
{
_id: new ObjectId(collaboratorId),
@ -142,6 +156,9 @@ export class CollaboratorService {
{
$set: {
permissions,
operator,
operatorId,
updatedAt: new Date(),
},
},
);

View File

@ -8,9 +8,10 @@ import { keyBy } from 'lodash';
import { DataItem } from 'src/interfaces/survey';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { getListHeadByDataList, transformAndMergeArrayFields } from '../utils';
import { QUESTION_TYPE } from 'src/enums/question';
@Injectable()
export class DataStatisticService {
private radioType = ['radio-star', 'radio-nps'];
private radioType = [QUESTION_TYPE.RADIO_STAR, QUESTION_TYPE.RADIO_NPS];
constructor(
@InjectRepository(SurveyResponse)
@ -33,8 +34,8 @@ export class DataStatisticService {
const dataListMap = keyBy(dataList, 'field');
const where = {
pageId: surveyId,
'curStatus.status': {
$ne: 'removed',
isDeleted: {
$ne: true,
},
};
const [surveyResponseList, total] =
@ -43,7 +44,7 @@ export class DataStatisticService {
take: pageSize,
skip: (pageNum - 1) * pageSize,
order: {
createDate: -1,
createdAt: -1,
},
});
@ -68,7 +69,7 @@ export class DataStatisticService {
}
// 处理选项的更多输入框
if (
this.radioType.includes(itemConfig.type) &&
this.radioType.includes(itemConfig.type as QUESTION_TYPE) &&
!data[`${itemConfigKey}_custom`]
) {
data[`${itemConfigKey}_custom`] =
@ -89,10 +90,10 @@ export class DataStatisticService {
}
return {
...data,
difTime: (submitedData.difTime / 1000).toFixed(2),
createDate: moment(submitedData.createDate).format(
'YYYY-MM-DD HH:mm:ss',
),
diffTime: submitedData.diffTime
? (submitedData.diffTime / 1000).toFixed(2)
: '0',
createdAt: moment(submitedData.createdAt).format('YYYY-MM-DD HH:mm:ss'),
};
});
return {
@ -123,8 +124,8 @@ export class DataStatisticService {
{
$match: {
pageId: surveyId,
'curStatus.status': {
$ne: 'removed',
isDeleted: {
$ne: true,
},
},
},

View File

@ -0,0 +1,273 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { ResponseSchema } from 'src/models/responseSchema.entity';
import { DownloadTask } from 'src/models/downloadTask.entity';
import { ObjectId } from 'mongodb';
import { ResponseSchemaService } from 'src/modules/surveyResponse/services/responseScheme.service';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { DataStatisticService } from './dataStatistic.service';
import xlsx from 'node-xlsx';
import { load } from 'cheerio';
import { get } from 'lodash';
import { FileService } from 'src/modules/file/services/file.service';
import { Logger } from 'src/logger';
import moment from 'moment';
import { DOWNLOAD_TASK_STATUS } from 'src/enums/downloadTaskStatus';
@Injectable()
export class DownloadTaskService {
static taskList: Array<any> = [];
static isExecuting: boolean = false;
constructor(
@InjectRepository(DownloadTask)
private readonly downloadTaskRepository: MongoRepository<DownloadTask>,
private readonly responseSchemaService: ResponseSchemaService,
@InjectRepository(SurveyResponse)
private readonly surveyResponseRepository: MongoRepository<SurveyResponse>,
private readonly dataStatisticService: DataStatisticService,
private readonly fileService: FileService,
private readonly logger: Logger,
) {}
async createDownloadTask({
surveyId,
responseSchema,
creatorId,
creator,
params,
}: {
surveyId: string;
responseSchema: ResponseSchema;
creatorId: string;
creator: string;
params: any;
}) {
const filename = `${responseSchema.title}-${params.isMasked ? '脱敏' : '原'}回收数据-${moment().format('YYYYMMDDHHmmss')}.xlsx`;
const downloadTask = this.downloadTaskRepository.create({
surveyId,
surveyPath: responseSchema.surveyPath,
fileSize: '计算中',
creatorId,
creator,
params: {
...params,
title: responseSchema.title,
},
filename,
status: DOWNLOAD_TASK_STATUS.WAITING,
});
await this.downloadTaskRepository.save(downloadTask);
return downloadTask._id.toString();
}
async getDownloadTaskList({
creatorId,
pageIndex,
pageSize,
}: {
creatorId: string;
pageIndex: number;
pageSize: number;
}) {
const where = {
creatorId,
isDeleted: {
$ne: true,
},
};
const [surveyDownloadList, total] =
await this.downloadTaskRepository.findAndCount({
where,
take: pageSize,
skip: (pageIndex - 1) * pageSize,
order: {
createdAt: -1,
},
});
return {
total,
list: surveyDownloadList,
};
}
async getDownloadTaskById({ taskId }) {
const res = await this.downloadTaskRepository.find({
where: {
_id: new ObjectId(taskId),
},
});
if (Array.isArray(res) && res.length > 0) {
return res[0];
}
return null;
}
async deleteDownloadTask({
taskId,
operator,
operatorId,
}: {
taskId: string;
operator: string;
operatorId: string;
}) {
return this.downloadTaskRepository.updateOne(
{
_id: new ObjectId(taskId),
},
{
$set: {
isDeleted: true,
operator,
operatorId,
deletedAt: new Date(),
},
},
);
}
processDownloadTask({ taskId }) {
DownloadTaskService.taskList.push(taskId);
if (!DownloadTaskService.isExecuting) {
this.executeTask();
DownloadTaskService.isExecuting = true;
}
}
async executeTask() {
try {
while (DownloadTaskService.taskList.length > 0) {
const taskId = DownloadTaskService.taskList.shift();
this.logger.info(`handle taskId: ${taskId}`);
const taskInfo = await this.getDownloadTaskById({ taskId });
if (!taskInfo || taskInfo.isDeleted) {
// 不存在或者已删除的,不处理
continue;
}
await this.handleDownloadTask({ taskInfo });
}
} finally {
DownloadTaskService.isExecuting = false;
}
}
async handleDownloadTask({ taskInfo }) {
try {
// 更新任务状态为计算中
const updateRes = await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
status: DOWNLOAD_TASK_STATUS.COMPUTING,
updatedAt: new Date(),
},
},
);
this.logger.info(JSON.stringify(updateRes));
// 开始计算任务
const surveyId = taskInfo.surveyId;
const responseSchema =
await this.responseSchemaService.getResponseSchemaByPageId(surveyId);
const where = {
pageId: surveyId,
};
const total = await this.surveyResponseRepository.count(where);
const pageSize = 200;
const pageTotal = Math.ceil(total / pageSize);
const xlsxHead = [];
const xlsxBody = [];
for (let pageIndex = 1; pageIndex <= pageTotal; pageIndex++) {
const { listHead, listBody } =
await this.dataStatisticService.getDataTable({
surveyId,
pageNum: pageIndex,
pageSize,
responseSchema,
});
if (xlsxHead.length === 0) {
for (const item of listHead) {
const $ = load(item.title);
const text = $.text();
xlsxHead.push(text);
}
}
for (const bodyItem of listBody) {
const bodyData = [];
for (const headItem of listHead) {
const field = headItem.field;
const val = get(bodyItem, field, '');
if (typeof val === 'string') {
const $ = load(val);
const text = $.text();
bodyData.push(text);
} else {
bodyData.push(val);
}
}
xlsxBody.push(bodyData);
}
}
const xlsxData = [xlsxHead, ...xlsxBody];
const buffer = await xlsx.build([
{ name: 'sheet1', data: xlsxData, options: {} },
]);
const file: Express.Multer.File = {
fieldname: 'file',
originalname: taskInfo.filename,
encoding: '7bit',
mimetype: 'application/octet-stream',
filename: taskInfo.filename,
size: buffer.length,
buffer: buffer,
stream: null,
destination: null,
path: '',
};
const { url, key } = await this.fileService.upload({
configKey: 'SERVER_LOCAL_CONFIG',
file,
pathPrefix: 'exportfile',
filename: taskInfo.filename,
});
// 更新计算结果
const updateFinishRes = await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
status: DOWNLOAD_TASK_STATUS.SUCCEED,
url,
fileKey: key,
fileSize: buffer.length,
updatedAt: new Date(),
},
},
);
this.logger.info(JSON.stringify(updateFinishRes));
} catch (error) {
await this.downloadTaskRepository.updateOne(
{
_id: taskInfo._id,
},
{
$set: {
status: DOWNLOAD_TASK_STATUS.FAILED,
updatedAt: new Date(),
},
},
);
this.logger.error(
`导出文件失败 taskId: ${taskInfo._id.toString()}, surveyId: ${taskInfo.surveyId}, message: ${error.message}`,
);
}
}
}

View File

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { Session } from 'src/models/session.entity';
import { ObjectId } from 'mongodb';
import { SESSION_STATUS } from 'src/enums/surveySessionStatus';
@Injectable()
export class SessionService {
constructor(
@InjectRepository(Session)
private readonly sessionRepository: MongoRepository<Session>,
) {}
create({ surveyId, userId }) {
const session = this.sessionRepository.create({
surveyId,
userId,
status: SESSION_STATUS.DEACTIVATED,
});
return this.sessionRepository.save(session);
}
findOne(sessionId) {
return this.sessionRepository.findOne({
where: {
_id: new ObjectId(sessionId),
},
});
}
findLatestEditingOne({ surveyId }) {
return this.sessionRepository.findOne({
where: {
surveyId,
status: SESSION_STATUS.ACTIVATED,
},
});
}
updateSessionToEditing({ sessionId, surveyId }) {
return Promise.all([
this.sessionRepository.update(
{
_id: new ObjectId(sessionId),
},
{
status: SESSION_STATUS.ACTIVATED,
updatedAt: new Date(),
},
),
this.sessionRepository.updateMany(
{
surveyId,
_id: {
$ne: new ObjectId(sessionId),
},
},
{
$set: {
status: SESSION_STATUS.DEACTIVATED,
updatedAt: new Date(),
},
},
),
]);
}
}

View File

@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { SurveyGroup } from 'src/models/surveyGroup.entity';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
@Injectable()
export class SurveyGroupService {
constructor(
@InjectRepository(SurveyGroup)
private readonly SurveyGroup: MongoRepository<SurveyGroup>,
@InjectRepository(SurveyMeta)
private surveyMetaRepository: MongoRepository<SurveyMeta>,
) {}
create(params: { name: string; ownerId: string }) {
const newGroup = this.SurveyGroup.create({
...params,
});
return this.SurveyGroup.save(newGroup);
}
async findAll(userId: string, name: string, skip: number, pageSize: number) {
const [list, total] = await this.SurveyGroup.findAndCount({
skip: skip,
take: pageSize,
where: name
? { name: { $regex: name, $options: 'i' }, ownerId: userId }
: { ownerId: userId },
order: {
createdAt: -1,
},
});
const allList = await this.SurveyGroup.find({
where: { ownerId: userId },
select: ['_id', 'name'],
});
return {
total,
list,
allList,
};
}
update(id: string, updatedFields: Partial<SurveyGroup>) {
updatedFields.updatedAt = new Date();
return this.SurveyGroup.update(id, updatedFields);
}
async remove(id: string) {
const query = { groupId: id };
const update = { $set: { groupId: null } };
await this.surveyMetaRepository.updateMany(query, update);
return this.SurveyGroup.delete(id);
}
}

View File

@ -45,9 +45,9 @@ export class SurveyHistoryService {
},
take: 100,
order: {
createDate: -1,
createdAt: -1,
},
select: ['createDate', 'operator', 'type', '_id'],
select: ['createdAt', 'operator', 'type', '_id'],
});
}
}

View File

@ -2,18 +2,18 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository, FindOptionsOrder } from 'typeorm';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { RECORD_STATUS } from 'src/enums';
import { RECORD_STATUS, RECORD_SUB_STATUS } from 'src/enums';
import { ObjectId } from 'mongodb';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { XiaojuSurveyPluginManager } from 'src/securityPlugin/pluginManager';
import { PluginManager } from 'src/securityPlugin/pluginManager';
@Injectable()
export class SurveyMetaService {
constructor(
@InjectRepository(SurveyMeta)
private readonly surveyRepository: MongoRepository<SurveyMeta>,
private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly pluginManager: PluginManager,
) {}
async getNewSurveyPath(): Promise<string> {
@ -47,6 +47,7 @@ export class SurveyMetaService {
createMethod: string;
createFrom: string;
workspaceId?: string;
groupId?: string;
}) {
const {
title,
@ -57,6 +58,7 @@ export class SurveyMetaService {
createFrom,
userId,
workspaceId,
groupId,
} = params;
const surveyPath = await this.getNewSurveyPath();
const newSurvey = this.surveyRepository.create({
@ -65,21 +67,48 @@ export class SurveyMetaService {
surveyType: surveyType,
surveyPath,
creator: username,
creatorId: userId,
owner: username,
ownerId: userId,
createMethod,
createFrom,
workspaceId,
groupId: groupId && groupId !== '' ? groupId : null,
});
return await this.surveyRepository.save(newSurvey);
}
async editSurveyMeta(survey: SurveyMeta) {
if (
survey.curStatus.status !== RECORD_STATUS.NEW &&
survey.curStatus.status !== RECORD_STATUS.EDITING
) {
async pausingSurveyMeta(survey: SurveyMeta) {
if (survey?.curStatus?.status === RECORD_STATUS.NEW) {
throw new HttpException(
'问卷不能暂停',
EXCEPTION_CODE.SURVEY_STATUS_TRANSFORM_ERROR,
);
}
const subCurStatus = {
status: RECORD_SUB_STATUS.PAUSING,
date: Date.now(),
};
survey.subStatus = subCurStatus;
if (Array.isArray(survey.statusList)) {
survey.statusList.push(subCurStatus);
} else {
survey.statusList = [subCurStatus];
}
return this.surveyRepository.save(survey);
}
async editSurveyMeta({
survey,
operator,
operatorId,
}: {
survey: SurveyMeta;
operator: string;
operatorId: string;
}) {
if (survey?.curStatus?.status !== RECORD_STATUS.EDITING) {
const newStatus = {
status: RECORD_STATUS.EDITING,
date: Date.now(),
@ -87,27 +116,26 @@ export class SurveyMetaService {
survey.curStatus = newStatus;
survey.statusList.push(newStatus);
}
survey.updatedAt = new Date();
survey.operator = operator;
survey.operatorId = operatorId;
return this.surveyRepository.save(survey);
}
async deleteSurveyMeta(survey: SurveyMeta) {
if (survey.curStatus.status === RECORD_STATUS.REMOVED) {
throw new HttpException(
'问卷已删除,不能重复删除',
EXCEPTION_CODE.SURVEY_STATUS_TRANSFORM_ERROR,
);
}
const newStatusInfo = {
status: RECORD_STATUS.REMOVED,
date: Date.now(),
};
survey.curStatus = newStatusInfo;
if (Array.isArray(survey.statusList)) {
survey.statusList.push(newStatusInfo);
} else {
survey.statusList = [newStatusInfo];
}
return this.surveyRepository.save(survey);
async deleteSurveyMeta({ surveyId, operator, operatorId }) {
return this.surveyRepository.updateOne(
{
_id: new ObjectId(surveyId),
},
{
$set: {
isDeleted: true,
operator,
operatorId,
deletedAt: new Date(),
},
},
);
}
async getSurveyMetaList(condition: {
@ -118,21 +146,40 @@ export class SurveyMetaService {
filter: Record<string, any>;
order: Record<string, any>;
workspaceId?: string;
groupId?: string;
surveyIdList?: Array<string>;
}): Promise<{ data: any[]; count: number }> {
const { pageNum, pageSize, userId, username, workspaceId, surveyIdList } =
condition;
const {
pageNum,
pageSize,
userId,
username,
workspaceId,
groupId,
surveyIdList,
} = condition;
const skip = (pageNum - 1) * pageSize;
try {
const query: Record<string, any> = Object.assign(
{},
{
'curStatus.status': {
$ne: 'removed',
isDeleted: {
$ne: true,
},
},
condition.filter,
);
if (condition.filter['curStatus.status']) {
query['subStatus.status'] = RECORD_SUB_STATUS.DEFAULT;
}
if (groupId && groupId !== 'all') {
query.groupId =
groupId === 'nogrouped'
? {
$exists: true,
$eq: null,
}
: groupId;
}
if (workspaceId) {
query.workspaceId = workspaceId;
} else {
@ -160,9 +207,8 @@ export class SurveyMetaService {
condition.order && Object.keys(condition.order).length > 0
? (condition.order as FindOptionsOrder<SurveyMeta>)
: ({
createDate: -1,
createdAt: -1,
} as FindOptionsOrder<SurveyMeta>);
const [data, count] = await this.surveyRepository.findAndCount({
where: query,
skip,
@ -181,6 +227,10 @@ export class SurveyMetaService {
date: Date.now(),
};
surveyMeta.curStatus = curStatus;
surveyMeta.subStatus = {
status: RECORD_SUB_STATUS.DEFAULT,
date: Date.now(),
};
if (Array.isArray(surveyMeta.statusList)) {
surveyMeta.statusList.push(curStatus);
} else {
@ -192,6 +242,23 @@ export class SurveyMetaService {
async countSurveyMetaByWorkspaceId({ workspaceId }) {
const total = await this.surveyRepository.count({
workspaceId,
isDeleted: {
$ne: true,
},
});
return total;
}
async countSurveyMetaByGroupId({ groupId }) {
const total = await this.surveyRepository.count({
groupId,
$or: [
{ workspaceId: { $exists: false } },
{ workspaceId: null },
{ workspaceId: '' },
],
isDeleted: {
$ne: true,
},
'curStatus.status': {
$ne: RECORD_STATUS.REMOVED,
},

View File

@ -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,21 +15,33 @@ 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 { SurveyGroupController } from './controllers/surveyGroup.controller';
import { SurveyConf } from 'src/models/surveyConf.entity';
import { SurveyHistory } from 'src/models/surveyHistory.entity';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { SurveyResponse } from 'src/models/surveyResponse.entity';
import { SurveyGroup } from 'src/models/surveyGroup.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 { SurveyGroupService } from './services/surveyGroup.service';
import { Session } from 'src/models/session.entity';
@Module({
imports: [
@ -39,11 +52,16 @@ import { CollaboratorService } from './services/collaborator.service';
SurveyResponse,
Word,
Collaborator,
Counter,
DownloadTask,
Session,
SurveyGroup,
]),
ConfigModule,
SurveyResponseModule,
AuthModule,
WorkspaceModule,
FileModule,
],
controllers: [
DataStatisticController,
@ -52,6 +70,9 @@ import { CollaboratorService } from './services/collaborator.service';
SurveyMetaController,
SurveyUIController,
CollaboratorController,
DownloadTaskController,
SessionController,
SurveyGroupController,
],
providers: [
DataStatisticService,
@ -62,6 +83,11 @@ import { CollaboratorService } from './services/collaborator.service';
ContentSecurityService,
CollaboratorService,
LoggerProvider,
CounterService,
DownloadTaskService,
FileService,
SessionService,
SurveyGroupService,
],
})
export class SurveyModule {}

View File

@ -43,7 +43,6 @@
"options": [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
@ -52,7 +51,6 @@
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",

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