Compare commits

..

345 Commits

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

* fix: 锁版本
2024-11-29 18:34:55 +08:00
dayou
c5fc734bb1 fix: 修复c端页面报错 (#448) 2024-10-30 18:02:55 +08:00
dayou
e6645c12a8 fix: 修复依赖版本 (#445)
* fix: 修复依赖版本

* fix: nanoid version

* fix: sass锁定1.79.6的版本

* nanoid reset

* fix: modern-compiler
2024-10-28 21:22:05 +08:00
luch1994
00c3e7c263 feat: 升级docker的版本 2024-10-08 21:33:40 +08:00
Donald Yang
ea6b0d50f0 feat:自动保存优化 (#435) 2024-10-08 21:33:29 +08:00
luch
f73bf6fdeb feat: 把report迁移到src下 (#440) 2024-10-08 21:33:16 +08:00
sudoooooo
039d634e62 feat: 优化readme 2024-10-08 15:48:00 +08:00
luch
0ce78b62a0
[Feature]: 升级状态相关功能 (#438)
* fix: 解决readme的冲突

* feat: 修改补充单测 (#437)
2024-09-30 19:18:55 +08:00
sudoooooo
2bc11c6dfe
feat: Create CODE_OF_CONDUCT.md 2024-09-27 11:13:05 +08:00
sudoooooo
e4f2cdede6 fix: 优化配置内容 2024-09-24 16:42:28 +08:00
Jiangchunfu
4b8719ab9c 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 16:42:20 +08:00
sudoooooo
4bc8fbc557 fix: 优化类型校验 2024-09-24 12:14:06 +08:00
sudoooooo
206f91d766 fix: 修改api 2024-09-23 15:20:29 +08:00
sudoooooo
8bece51a20 fix: 修复投票题问题 2024-09-23 14:22:35 +08:00
dayou
0ea1d81ad3 fix: 修复最少最多选择 (#412)
* fix: 修复最少最多选择

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

* fix: 回退最多最少选择

* perl: 代码优化以及fix lint

---------

Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-09-23 14:07:22 +08:00
Jiangchunfu
602f8f4b73 fix: 修复做题页面提交reload页面 (#419)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
Co-authored-by: sudoooooo <zjbbabybaby@gmail.com>
2024-09-23 14:07:12 +08:00
sudoooooo
930976f67e fix: 优化schema初始化管理 2024-09-23 14:07:02 +08:00
sudoooooo
e6b4c02e34 fix: 优化代码内容 2024-09-23 14:06:41 +08:00
luch
8f65b1390b feat: 修改导出和登录状态检测代码CR问题 (#431) 2024-09-23 14:06:36 +08:00
sudoooooo
c6239b2770 fix: 修复写法问题、C端组件问题 2024-09-23 14:06:19 +08:00
dayou
bf5db3f47b fix: 断点续答and编辑检测代码cr优化 (#428)
* fix: 优化代码以及去掉无用字段

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

* fix: 优化字段

* fix: lint

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

* fix: lint

* fix: 第二批cr优化

* fix: 去掉无用代码

* fix: 调整session守卫位置

* fix: 文件大小写
2024-09-23 14:05:39 +08:00
sudoooooo
628872f27c fix: 移除有问题的功能 2024-09-23 14:05:31 +08:00
dayou
dfea4b4779 【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-23 14:05:02 +08:00
Jiangchunfu
afbd63646a fix: 修复富文本编辑器上传图片 (#410)
* fix: 修复题目标题插入图片异常问题

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

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-09-23 14:03:59 +08:00
Stahsf
39c80352b3 test: 密码检测用例 (#411)
* feat: 密码复杂度检测

* chore: 改为服务端校验

* test: 修改用例

* test: 添加getPasswordStrength测试用例
2024-09-23 14:03:54 +08:00
sudoooooo
16b02fcbaa feat: 优化登录窗口 2024-09-23 14:03:47 +08:00
sudoooooo
e197aa41fe fix: 删除多余内容 2024-09-23 14:03:40 +08:00
Jiangchunfu
2d6bba2224 feat(选项设置扩展):选择类题型增加选项排列配置 (#403)
* build: add optimizeDeps packages

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

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

---------

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

* fix lint
2024-09-23 14:02:27 +08:00
sudoooooo
f5c2bd88f2 feat: 优化展示 2024-09-23 14:02:18 +08:00
Stahsf
1c6908b6a5 [Feature]: 密码复杂度检测 (#407)
* feat: 密码复杂度检测

* chore: 改为服务端校验
2024-09-23 14:01:43 +08:00
sudoooooo
99739064cc feat: 修改readme 2024-08-30 12:01:57 +08:00
sudoooooo
dc82ee9be6 feat: 优化换行 2024-08-14 21:32:42 +08:00
dayou
7b710d4d13 fix: 删除分页判断是否存在题目的逻辑关联 (#402) 2024-08-14 21:32:32 +08:00
Liang-Yaxin
2ecbc53983 fix: 问卷列表更多按钮图标优化 (#401) 2024-08-14 21:16:23 +08:00
dayou
c3f8b2a938 feat: 跳转逻辑稳定版 (#399)
* feat: 跳转逻辑 (#388)

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

* fix: 跳转逻辑优化

* fix: processJumpSkip逻辑放在题目组件中
2024-08-14 18:06:49 +08:00
sudoooooo
e7adb05c3d fix: 修复高级设置迁移后无法交互问题 2024-08-14 18:06:38 +08:00
dayou
5e30c2898e fix: 更新iconfont链接 (#398) 2024-08-14 18:06:27 +08:00
sudoooooo
7e83c926ae feat: 高级设置组件迁移 2024-08-14 18:05:38 +08:00
sudoooooo
26f8526f70 feat: 升级iconfont 2024-08-13 11:47:20 +08:00
sudoooooo
b5bce230c1 fix: 新建问卷重置计数 2024-08-12 23:16:11 +08:00
Jiangchunfu
624308bae8 feat: 整卷增加基础配置:必填、显示类型、显示序号、显示分割线 (#391)
* feat: 增加整卷配置功能

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

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 23:16:05 +08:00
Jiangchunfu
28591f00a3 feat: C端增加重新填写入口, #182 (#392)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 23:15:53 +08:00
sudoooooo
23f4dd5756 feat: 优化readme 2024-08-12 19:42:21 +08:00
sudoooooo
9392b93d10 fix: 连续添加题目频繁触发事件导致页面卡顿 2024-08-12 16:09:40 +08:00
sudoooooo
da08621dc6 feat: action不处理format 2024-08-12 14:10:08 +08:00
Jiangchunfu
3ba41f78ad refactor: 题目删除和逻辑关联优化,#182 (#393)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-12 14:10:03 +08:00
sudoooooo
c8c1031c29 fix: 皮肤设置tab问题 2024-08-07 22:10:35 +08:00
sudoooooo
0b6d48bddc fix: 修复白名单切换和空间成员名字问题 2024-08-07 22:10:32 +08:00
Jiangchunfu
4e993f4d55 refactor: 设置器加载统一,代码优化 #269 (#383)
* feat: 小功能建设(4)

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

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-07 14:56:15 +08:00
sudoooooo
12206fd3ee feat: 修改Readme 2024-08-06 23:46:39 +08:00
sudoooooo
3cc76d0b61 feat: 修改字段 2024-08-06 19:59:52 +08:00
sudoooooo
4a158ae6e8 feat: 优化分页器结构 2024-08-06 19:33:31 +08:00
chaorenluo
c330e6000d feat:新增分页功能 (#382)
* feat:新增分页功能

* 修复type-check检查

* fix: server  type-check

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

* fix:修复分页bug
2024-08-06 18:08:36 +08:00
Jiangchunfu
d9e9770eb8 feat: 小功能建设(4) (#379) 2024-08-04 12:41:24 +08:00
Jiangchunfu
0745d90a5c
fix: 修复题目未聚焦时拖拽按钮失效问题 (#375)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-08-04 12:38:59 +08:00
sudoooooo
e58be83214 fix: 修复字段difTime->diffTime 2024-08-04 12:21:37 +08:00
Jiangchunfu
9dbe7cfa2b 小功能优化8 (#371)
* refactor: 去除重复元素

* feat: edit 布局优化

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

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-31 20:32:26 +08:00
离谱
15d93abea3 fix(Navbar):修复了短标题hover空白处会触发指示框的bug (#365)
fix(Navbar):修复了短标题hover空白处会触发指示框的bug
2024-07-30 15:41:27 +08:00
dayou
9ca27118c4 fix: 修复提交设置 (#370) 2024-07-30 15:41:20 +08:00
sudoooooo
29b37b7ed0 fix: 修复白名单密码问题 2024-07-23 22:50:50 +08:00
sudoooooo
d36e33e4df feat: 优化结构 2024-07-23 22:22:27 +08:00
sudoooooo
4ac07ef938 feat: 优化编辑页模块和配置结构 2024-07-23 22:07:46 +08:00
Stahsf
ee9a1ea9c7 feat: 白名单功能-服务端 (#357) 2024-07-23 22:01:37 +08:00
chaorenluo
df6e14c585 feat: 前端新增白名单功能 2024-07-23 22:01:26 +08:00
sudoooooo
0b53b78cda Merge branch 'feature/pinia' 2024-07-23 21:27:04 +08:00
dayou
6c396c6ec8 fix: 修复设置器不更新问题 (#361) 2024-07-23 15:40:44 +08:00
Jiangchunfu
591a98bff1
fix: 修复新增同类型题目设置值不变问题 (#359)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-23 14:17:31 +08:00
sudoooooo
9afd1d1c7c fix: render错误数据不同步问题 2024-07-22 17:18:37 +08:00
ysansan
6b7b3a12d8
feat: pinia迁移--问卷相关遗漏补充 (#355)
Co-authored-by: Ysansan <ysansan98@outlook.com>
2024-07-19 18:55:22 +08:00
sudoooooo
41d072bc90 feat: 优化图片尺寸用于移动端 2024-07-18 21:08:59 +08:00
nil
358e822660 feat: 题目与选项支持图片 (#291)
* feat: 题目与选项支持图片

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

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

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

* fix: 两个#app的问题
2024-07-18 21:08:50 +08:00
sudoooooo
3388a15462 feat: 更新readme和docker tag 2024-07-17 23:12:40 +08:00
yoruponder
bd603eccfb
feat: errorInfo to pinia (#353) 2024-07-17 20:11:41 +08:00
dayou
7ff691471b
fix: user模块升级为ts (#348) 2024-07-16 14:32:28 +08:00
sudoooooo
ea5f99b0d2 fix: format报错 2024-07-16 11:33:41 +08:00
Jiangchunfu
c0387b1521 feat: 题型硬编码优化 (#343)
Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-16 11:33:35 +08:00
hiStephen
09866663f2
feat: 答题页相关vuex迁移pinia (#341) 2024-07-16 10:56:39 +08:00
sudoooooo
1eabbf5df2 feat: 代码format、升级ts和lint 2024-07-15 22:40:19 +08:00
Ken
7c336e2320 feat: 空间列表分页和搜索及数据列表优化 (#344)
1、fix: 修复因滚动条宽度影响皮肤标签的问题
2、feat: 空间列表分页和搜索及数据列表样式优化
3、feat: 对部分代码进行了优化
2024-07-15 22:40:13 +08:00
sudoooooo
2044da4be9 fix: 移除选项默认选中 2024-07-15 22:40:05 +08:00
Jiangchunfu
a14d444960 feat: 功能18优化 (#342)
题目标题编辑自动focus
2024-07-15 22:39:53 +08:00
sudoooooo
5e85e5a3b9 fix: 空间列表高度 2024-07-15 22:39:45 +08:00
ysansan
99a1eeb356 问卷编辑页面题型选择tab、团队空间列表按钮优化 (#337)
* feat:问卷编辑页面题型选择tab优化

* feat: 团队空间列表按钮优化
2024-07-15 22:39:05 +08:00
dayou
f08c8bcd2a
feature: 搭建端全局模块下的变量迁移pinia-edit模块 (#336)
* fix: 搭建端的pinia迁移完成

* fix:删除store
2024-07-15 11:23:09 +08:00
sudoooooo
e42625f1aa fix: 避免element-plus提示sass语法 2024-07-15 11:21:56 +08:00
sudoooooo
b26d7b08c6 feat: 增加csdn和x 2024-07-12 16:50:26 +08:00
sudoooooo
f82de45a03 feat: 优化协作管理路径 2024-07-10 18:37:42 +08:00
Ken
e8e2a9ab2c feat: 协作者管理优化和皮肤标签样式调整 (#333)
* feat: 皮肤设置内主题分类标签样式调整

* feat: 协作者管理优化
2024-07-10 18:37:36 +08:00
Jiangchunfu
b2c3d2c0c3 feat(小功能建设): 15团队空间问卷列表页优化 (#334) 2024-07-10 17:09:43 +08:00
sudoooooo
d8adb4596c fix: sass1.77.7规则升级warning 2024-07-10 17:09:37 +08:00
hiStephen
e757da19eb feat: echarts按需引入 (#332) 2024-07-10 17:09:30 +08:00
sudoooooo
5a8fab4e4b feat: 优化引入、lint & format 2024-07-10 15:30:39 +08:00
yoruponder
2ad6a77740
feat: survey vuex to pinia (#331)
* feat: survey vuex to pinia

* feat: fix ts type-check

* feat: fix lint error
2024-07-10 14:41:36 +08:00
Jiangchunfu
1a15faad42
feat: edit vuex迁移pinia (#325)
* build: dev reload optimized

* feat: edit store change pinia

* feat: store中状态修改增加方法

* feat: js改为ts

---------

Co-authored-by: jiangchunfu <jiangchunfu@kaike.la>
2024-07-10 14:05:41 +08:00
sudoooooo
d7c772e748 fix: 修复预览页logo展示问题 2024-07-09 15:24:03 +08:00
Jiangchunfu
9fbbb87fa8 小功能优化 (#329)
* feat(换肤设置优化): 边距的颜色优化成背景色一致

* feat: 问卷设置优化
2024-07-09 15:23:56 +08:00
Ken
63e7de4fad feat: 移动端预览优化 (#326)
* feat: 移动端预览优化

* feat: C端底部logo优化
2024-07-09 12:13:40 +08:00
sudoooooo
64a1caf0dd feat: list模块优化 2024-07-09 11:36:18 +08:00
ysansan
33f18742dd
feat: list模块的pinia迁移 (#323)
* feat: list模块的pinia迁移

* fix: type check error

* fix: reserSelectValueMap, reserButtonValueMap 命名修正,addSpace和updateSpace 参数类型约束

---------

Co-authored-by: Ysansan <ysansan98@outlook.com>
2024-07-08 21:54:59 +08:00
sudoooooo
ce3508f8be fix: 修复nginx启动render页空白问题 2024-07-08 21:07:15 +08:00
sudoooooo
2ab95463c5 fix: 优化依赖项 2024-07-01 15:35:10 +08:00
若川
dc91b69bf5 fix: 🐛 修复引入 import lodash cloneDeep debounce 错误 改为 lodash-es 了 (#322) 2024-07-01 15:35:01 +08:00
dayou
5b96ad7c69 feat: rener stores文件占位 2024-06-28 19:05:07 +08:00
dayou
cdd26073af
feat: user模块的pinia迁移 (#315) 2024-06-28 18:46:24 +08:00
sudoooooo
83dc99e1a8 fix: fix warning 2024-06-28 17:42:18 +08:00
dayou
335765e3ea feat: 物料脱离store依赖 (#312) 2024-06-28 17:42:05 +08:00
dayou
d50f974c3d no message 2024-06-28 17:41:50 +08:00
sudoooooo
f031f5fc7c fix: fix warning 2024-06-28 17:14:23 +08:00
dayou
39b6b1a53f
feat: 物料脱离store依赖 (#312) 2024-06-28 17:09:00 +08:00
dayou
d6dc68429a
feat: schema分离 (#311) 2024-06-28 17:08:55 +08:00
sudoooooo
4d71238084 fix: 修复文档内容 2024-06-24 17:50:17 +08:00
sudoooooo
79e06ff40c fix: 修复文档内容 2024-06-24 17:49:59 +08:00
sudoooooo
a7fa577837 fix: 修复预览兼容 2024-06-24 16:14:05 +08:00
sudoooooo
393fa21d4c fix: 修复预览兼容 2024-06-24 16:13:44 +08:00
sudoooooo
f7e5995add feat: 调整文档内容 2024-06-24 10:50:01 +08:00
sudoooooo
8f53b0f6f9 feat: 调整文档内容 2024-06-24 10:49:40 +08:00
yelang
a679ad20bb [refactor]: check for the ispc-html class is done using a regular expression to ensure that the class is only added once when the browser window is resized (#301) 2024-06-21 21:00:37 +08:00
yelang
9753215281
[refactor]: check for the ispc-html class is done using a regular expression to ensure that the class is only added once when the browser window is resized (#301) 2024-06-21 20:59:58 +08:00
hiStephen
2ab96606aa feat: 分题统计前端开发 (#276) 2024-06-21 16:37:11 +08:00
luch
0b42899347 feat: 新增分题统计相关功能 (#275) 2024-06-21 16:37:05 +08:00
hiStephen
cce508d17a
feat: 分题统计前端开发 (#276) 2024-06-21 16:33:20 +08:00
luch
1f1dd86f89
feat: 新增分题统计相关功能 (#275) 2024-06-21 16:24:49 +08:00
dayou
053d9751c3 feat: c端路由改造 (#296) 2024-06-14 19:21:16 +08:00
dayou
2e1af4ae3a
feat: c端路由改造 (#296) 2024-06-14 19:20:36 +08:00
dayou
32b1c4888d feat: 权限接口请求优化以及修复其他问题 (#290) 2024-06-14 19:07:14 +08:00
dayou
c122589a2e feat: 抽离题型枚举 (#272)
* feat: 抽离题型枚举

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

* feat: serve端的node engines
2024-06-14 19:07:06 +08:00
dayou
e0537ab706
feat: 权限接口请求优化以及修复其他问题 (#290) 2024-06-14 19:05:23 +08:00
dayou
a428b787e7
feat: 抽离题型枚举 (#272)
* feat: 抽离题型枚举

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

* feat: serve端的node engines
2024-06-14 18:54:11 +08:00
Realabiha
6559154bca refactor: 重构vue3组合式API写法 (#265) 2024-06-11 20:10:46 +08:00
sudoooooo
827497114d fix: 优化预览展示 2024-06-11 20:10:39 +08:00
chaorenluo
48fdb3138a feat:新增预览功能 (#257)
* feat:问卷预览功能
* feat:修复样式问题
2024-06-11 20:10:32 +08:00
Realabiha
7bb7b0c7fd
refactor: 重构vue3组合式API写法 (#265) 2024-06-11 19:35:55 +08:00
sudoooooo
cd75ac18bc fix: 优化预览展示 2024-06-11 19:30:29 +08:00
chaorenluo
70827bbd5f
feat:新增预览功能 (#257)
* feat:问卷预览功能
* feat:修复样式问题
2024-06-11 16:04:00 +08:00
alwayrun
cffe037269 refactor: 重构 management/pages/edit 目录下组件, 使用 Vue3 组合式 API 写法 (#251) 2024-06-05 17:30:31 +08:00
sudoooooo
d9255db8a9 feat: Update README.md 2024-06-05 11:46:24 +08:00
sudoooooo
89b249274f feat: Update README_EN.md 2024-06-05 11:46:19 +08:00
sudoooooo
2cd79e6091
feat: Update README.md 2024-06-05 11:45:24 +08:00
sudoooooo
90d013bce5
feat: Update README_EN.md 2024-06-05 11:44:31 +08:00
alwayrun
4b0f3f793e
refactor: 重构 management/pages/edit 目录下组件, 使用 Vue3 组合式 API 写法 (#251) 2024-06-04 21:01:26 +08:00
sudoooooo
e0da70838a feat: 优化空间展示和文案 2024-06-04 18:05:30 +08:00
sudoooooo
a2d75a4ed7 feat: 优化空间展示和文案 2024-06-04 18:05:01 +08:00
sudoooooo
c70a93e54e fix: 修复defineProps无需import的warning 2024-06-04 16:26:33 +08:00
sudoooooo
dbf8c4a827 fix: 修复defineProps无需import的warning 2024-06-04 16:25:58 +08:00
sudoooooo
be9da7811d feat: 优化初始化请求 2024-06-04 16:18:26 +08:00
Jiang Yu
8a4cad1195 Update HistoryPanel.vue (#256)
1、点击“修改历史”:请求修改历史列表;点击“发布历史”:请求发布历史列表。
2、接口请求过程中增加loading进行交互优化。
2024-06-04 16:18:17 +08:00
sudoooooo
77eb0337d1 feat: 优化初始化请求 2024-06-04 16:10:29 +08:00
Jiang Yu
018e69232d
Update HistoryPanel.vue (#256)
1、点击“修改历史”:请求修改历史列表;点击“发布历史”:请求发布历史列表。
2、接口请求过程中增加loading进行交互优化。
2024-06-04 16:08:42 +08:00
alwayrun
e2e82dc3f3 fix: 修复物料基础组件 BaseRate 重复触发 change 事件问题 (#254) 2024-06-04 15:36:54 +08:00
alwayrun
efabb958aa
fix: 修复物料基础组件 BaseRate 重复触发 change 事件问题 (#254) 2024-06-04 15:36:18 +08:00
xixiIBN5100
caca627b18 perf: 显示逻辑展示优化 (#249) 2024-06-04 15:31:30 +08:00
xixiIBN5100
01eb1dda73
perf: 显示逻辑展示优化 (#249) 2024-06-04 15:27:15 +08:00
dayou
7ad01f6b26 feat: 问卷空间协作能力前端 (#246) 2024-06-03 18:05:48 +08:00
luch
9448d3c111 feat: 新增空间和协作功能 (#252) 2024-06-03 18:05:40 +08:00
sudoooooo
b26855de90
feat: Update README_EN.md 2024-06-03 16:47:39 +08:00
sudoooooo
b224077d19
feat: Update README_EN.md 2024-06-03 16:46:42 +08:00
sudoooooo
725ca7bdd3 feat: 增加 README_EN.md 2024-06-03 16:45:26 +08:00
sudoooooo
c363c13d37
feat: Update README.md 2024-06-03 16:38:18 +08:00
sudoooooo
1214301ae4 feat: 扩展README_EN 2024-06-03 16:36:59 +08:00
dayou
404ba360b9
feat: 问卷空间协作能力前端 (#246) 2024-05-30 22:11:11 +08:00
luch
f9d75962ed
feat: 新增空间和协作功能 (#252) 2024-05-30 21:32:14 +08:00
codeniu
c904fd3932 feat: 问卷列表操作栏固定 & 登录校验优化(jtest测试文件更新) (#241)
* refactor: 使用vue3组合式API重构登录页代码

* feat: 问卷列表操作栏固定

* feat: 登录校验优化
2024-05-30 15:32:30 +08:00
xixiIBN5100
f31cd0f773 perf: 问卷编辑页标题优化 (#240)
* refactor: 重构 src/management/pages/edit/modules 目录下组件, 使用 Vue3 组合式 API 写法

* perf: 问卷编辑页标题优化
2024-05-30 15:32:21 +08:00
sudoooooo
34cc06cd9c feat: 优化样式 2024-05-30 15:32:12 +08:00
chaorenluo
5f8896eec2 refactor:统一B,C端渲染组件,将其抽离到物料区 (#184)
* feat:抽离B,C端通用组件

* refactor: 通用组件统一渲染

* 兼容修复问题 (+1 squashed commit)
Squashed commits:
[8d168ef] refactor: 替换统一渲染组件

* refactor:统一B,C端渲染组件,将其抽离到物料区
2024-05-30 15:32:04 +08:00
codeniu
17b84ef501
feat: 问卷列表操作栏固定 & 登录校验优化(jtest测试文件更新) (#241)
* refactor: 使用vue3组合式API重构登录页代码

* feat: 问卷列表操作栏固定

* feat: 登录校验优化
2024-05-30 15:17:03 +08:00
xixiIBN5100
8db8f9ab19
perf: 问卷编辑页标题优化 (#240)
* refactor: 重构 src/management/pages/edit/modules 目录下组件, 使用 Vue3 组合式 API 写法

* perf: 问卷编辑页标题优化
2024-05-30 14:40:53 +08:00
sudoooooo
ae6907a3f4 feat: 优化样式 2024-05-29 22:04:44 +08:00
chaorenluo
4115ff9847
refactor:统一B,C端渲染组件,将其抽离到物料区 (#184)
* feat:抽离B,C端通用组件

* refactor: 通用组件统一渲染

* 兼容修复问题 (+1 squashed commit)
Squashed commits:
[8d168ef] refactor: 替换统一渲染组件

* refactor:统一B,C端渲染组件,将其抽离到物料区
2024-05-29 21:59:13 +08:00
alwayrun
ea342a0d0b fix: 修复题型拖拽移动问题 (#218) 2024-05-29 21:36:22 +08:00
alwayrun
b18677e709
fix: 修复题型拖拽移动问题 (#218) 2024-05-29 21:35:43 +08:00
alwayrun
1bd2968982 fix: 修复问卷编辑-活动题型移动选区未移动问题 (#187) 2024-05-29 21:27:25 +08:00
alwayrun
df49b8f692
fix: 修复问卷编辑-活动题型移动选区未移动问题 (#187) 2024-05-29 21:25:50 +08:00
sudoooooo
3dc15aeb68
feat: Update README.md 2024-05-29 11:45:16 +08:00
sudoooooo
668e8a6ba7
feat: Update README.md 2024-05-29 10:41:40 +08:00
alwayrun
de2af7931f fix: 调整重构 setters 组件下几处 watch 初始值引发无法监听问题 (#178) 2024-05-28 18:24:36 +08:00
alwayrun
a64e820e80 fix: 题型切换 CheckboxGroup 基础设置组件状态监控导致问题 (#177) 2024-05-28 18:24:30 +08:00
alwayrun
eb6ba7a1a5 fix: 修复物料设置组件 RangeSetter 在设置最大值与最小值引发问题 (#175) 2024-05-28 18:24:21 +08:00
alwayrun
66b7248349
fix: 调整重构 setters 组件下几处 watch 初始值引发无法监听问题 (#178) 2024-05-28 18:16:55 +08:00
alwayrun
371a3e1078
fix: 题型切换 CheckboxGroup 基础设置组件状态监控导致问题 (#177) 2024-05-28 18:15:28 +08:00
alwayrun
08feb06d9c
fix: 修复物料设置组件 RangeSetter 在设置最大值与最小值引发问题 (#175) 2024-05-28 14:49:46 +08:00
sudoooooo
89b69a9fa1 fix: C端背景和题型分割线 2024-05-27 21:38:14 +08:00
sudoooooo
8c0563ca83 fix: C端背景和题型分割线 2024-05-27 21:35:07 +08:00
codeniu
dc7542de60 refactor: 使用vue3组合式API重构登录页代码 (#172) 2024-05-27 16:55:43 +08:00
codeniu
29011194c7
refactor: 使用vue3组合式API重构登录页代码 (#172) 2024-05-27 16:54:32 +08:00
sudoooooo
b18d872c66
feat: Update README.md 2024-05-27 16:47:35 +08:00
alwayrun
fd6585d80c refactor: 重构 management/pages/publishResult 目录下组件, 使用 Vue3 组合式 API 写法 (#167) 2024-05-27 16:19:15 +08:00
alwayrun
9526faeec1
refactor: 重构 management/pages/publishResult 目录下组件, 使用 Vue3 组合式 API 写法 (#167) 2024-05-27 16:18:08 +08:00
alwayrun
4b8bcac049 feat: 题型处于活动状态时操作侧边栏默认显示 (#157) 2024-05-27 16:12:50 +08:00
alwayrun
994a200415
feat: 题型处于活动状态时操作侧边栏默认显示 (#157) 2024-05-27 16:10:42 +08:00
dayou
4c85fcc47e format: 代码格式化 (#160) 2024-05-24 20:47:54 +08:00
dayou
38566f1f60 fix:解决ts报错 (#110) 2024-05-24 20:46:50 +08:00
hiStephen
89d416becd refactor: Vue3 组合式 API 写法重构 (#165) 2024-05-24 20:34:19 +08:00
alwayrun
de7344b192 fix: 修复物料题型通用组件 BaseChoice 单选类型题重复触发 change 事件问题 (#164) 2024-05-24 20:34:06 +08:00
hiStephen
2b683b0923
refactor: Vue3 组合式 API 写法重构 (#165) 2024-05-24 20:28:43 +08:00
alwayrun
0095e3b7e0
fix: 修复物料题型通用组件 BaseChoice 单选类型题重复触发 change 事件问题 (#164) 2024-05-24 20:28:01 +08:00
sudoooooo
39ff6acf99
feat: Update README.md 2024-05-24 11:28:49 +08:00
sudoooooo
e2687865ef
Update README.md 2024-05-24 10:20:54 +08:00
sudoooooo
fe3159105f
feat: Update README.md 2024-05-24 10:19:50 +08:00
dayou
d3378ada21
format: 代码格式化 (#160) 2024-05-23 21:52:57 +08:00
sudoooooo
49f0bfbd71 feat: 增加类型检测 2024-05-23 17:15:15 +08:00
sudoooooo
ced773d77b feat: 增加类型检测 2024-05-23 17:13:38 +08:00
alwayrun
24881d7cdc fix: 修复活动题型选区多次点击重复触发 select 事件 (#155) 2024-05-23 16:41:09 +08:00
sudoooooo
ae58f9e06d fix: 修复单选题报错问题 2024-05-23 16:39:44 +08:00
alwayrun
57ec0b3b7f fix: 修复活动题型选区多次点击重复触发 select 事件 (#155) 2024-05-23 16:39:34 +08:00
sudoooooo
627eabd8b2 fix: 修复单选题报错问题 2024-05-23 16:23:24 +08:00
alwayrun
3242ad80c4 refactor: 重构 management/components 目录下组件, 使用 Vue3 组合式 API 写法 (#151) 2024-05-23 11:54:37 +08:00
dayou
8ce6dc7607 fix: 修复attrs透传会触发两次select事件 (#138) 2024-05-23 11:54:29 +08:00
alwayrun
57918272ac
refactor: 重构 management/components 目录下组件, 使用 Vue3 组合式 API 写法 (#151) 2024-05-23 11:49:48 +08:00
dayou
734e33edbb
fix: 修复attrs透传会触发两次select事件 (#138) 2024-05-23 11:39:09 +08:00
sudoooooo
0b0f78a3ed
feat: Update README.md 2024-05-23 11:37:53 +08:00
dayou
142b3b7be9 feat: 显示逻辑稳定版 (#149) 2024-05-21 21:44:25 +08:00
dayou
58ab49e974
feat: 显示逻辑稳定版 (#149) 2024-05-21 21:31:55 +08:00
sudoooooo
21cba5946b feat: add format 2024-05-21 19:43:38 +08:00
sudoooooo
4f116fdcc4 feat: add branches 2024-05-21 19:43:11 +08:00
sudoooooo
bf5750c634 feat: add branches 2024-05-21 19:42:43 +08:00
sudoooooo
f782ddf176 feat: add format 2024-05-21 19:38:18 +08:00
alwayrun
6c9044b457 refactor: 重构 materials/setters/widgets 目录下部分组件, 使用 Vue3 组合式 API 写法 (#146) 2024-05-21 19:29:39 +08:00
alwayrun
3f7fb8b68c
refactor: 重构 materials/setters/widgets 目录下部分组件, 使用 Vue3 组合式 API 写法 (#146) 2024-05-21 19:28:42 +08:00
sudoooooo
e9b85f7878
feat: Update README.md 2024-05-20 20:50:36 +08:00
alwayrun
5e50a3a733 refactor: 重构 render/components 目录下部分组件, 使用 Vue3 组合式 API 写法 (#115) 2024-05-20 20:43:19 +08:00
alwayrun
7b0c1c43c9
refactor: 重构 render/components 目录下部分组件, 使用 Vue3 组合式 API 写法 (#115) 2024-05-20 20:42:37 +08:00
sudoooooo
eed15cd23c feat: nginx配置 2024-05-20 20:21:25 +08:00
sudoooooo
3629796786 feat: nginx配置 2024-05-20 20:20:48 +08:00
sudoooooo
b35cad82d3 fix: 修改docker-compose 2024-05-20 20:18:59 +08:00
chaorenluo
95917e22f0 fix:前端项目使用nginx服务代理,不在使用后端服务代理 (#100)
* fix:前端项目使用nginx服务代理,不在使用后端服务代理

* fix:修改备注
2024-05-20 18:48:13 +08:00
sudoooooo
f5d3f0d683 feat: 修改nginx端口 2024-05-20 18:37:12 +08:00
alwayrun
ac33ffa34a fix: 修复问卷编辑页最后一题处于活动状态下删除导致问题 (#141) 2024-05-20 17:50:52 +08:00
alwayrun
65884f80f6 refactor: 重构 render/App.vue, 使用 Vue3 组合式 API 写法 (#117) 2024-05-20 17:50:44 +08:00
alwayrun
9ccadb96f9
fix: 修复问卷编辑页最后一题处于活动状态下删除导致问题 (#141) 2024-05-20 17:48:48 +08:00
sudoooooo
127a589b2c
feat: Update README.md 2024-05-17 15:55:45 +08:00
luch
ba3b1a64e5 fix: 修改alioss.handler中单测文件的报错 (#105) 2024-05-17 15:32:16 +08:00
luch
00d5e98712 [Feature]: 补充file模块的单测 (#102)
* feat: 补充file模块的单测

* fix: 安装types依赖
2024-05-17 15:32:08 +08:00
alwayrun
318020ead7
refactor: 重构 render/App.vue, 使用 Vue3 组合式 API 写法 (#117) 2024-05-17 11:48:51 +08:00
sudoooooo
91838194bb feat: 增加lint检测和覆盖率报告 2024-05-17 11:20:27 +08:00
sudoooooo
be5d48fa71 feat: 增加lint检测和覆盖率报告 2024-05-17 11:19:39 +08:00
sudoooooo
6319ca272e feat: 自动打开浏览器 2024-05-16 21:20:59 +08:00
nil
6350c95536 Feature/dnd (#132)
* feat: 增加拖拽添加题目效果

* feat: 手动实现题型的预览效果

* feat: 优化预览体验
2024-05-16 21:20:49 +08:00
sudoooooo
3984412646 feat: 自动打开浏览器 2024-05-16 21:15:34 +08:00
nil
f67a6e6a45
Feature/dnd (#132)
* feat: 增加拖拽添加题目效果

* feat: 手动实现题型的预览效果

* feat: 优化预览体验
2024-05-16 21:12:59 +08:00
sudoooooo
ee4049b947
feat: Update README.md 2024-05-16 14:50:25 +08:00
sudoooooo
76683371cf feat: lint 2024-05-15 20:36:13 +08:00
alwayrun
f9af6219ab refactor: 重构 render/pages 目录下三个文件, 使用 Vue3 组合式 API 写法 (#113) 2024-05-15 20:36:04 +08:00
sudoooooo
98557b70ec feat: lint 2024-05-15 20:34:56 +08:00
alwayrun
5c57d5f5c3
refactor: 重构 render/pages 目录下三个文件, 使用 Vue3 组合式 API 写法 (#113) 2024-05-15 20:32:57 +08:00
dayou
c67d8dd134
fix:解决ts报错 (#110) 2024-05-13 14:01:46 +08:00
sudoooooo
2560f0af3d
feat: Update README.md 2024-05-11 01:18:38 +08:00
sudoooooo
255bd8bab8 feat: 修改docker配置 2024-05-11 01:15:39 +08:00
Weiguo Wang
6771b831e5 feat: vue3 (#103) 2024-05-11 00:00:51 +08:00
sudoooooo
2bca93bf12
feat: Update README.md 2024-05-10 22:54:22 +08:00
sudoooooo
c3bbd35c2c
feat: Update README.md 2024-05-10 22:52:41 +08:00
sudoooooo
5d88d9ad59
feat: Update README.md 2024-05-10 21:08:39 +08:00
sudoooooo
39b25acc50
feat: Update README.md 2024-05-10 21:04:35 +08:00
sudoooooo
a58bfe79cc
feat: Update README.md 2024-05-10 10:23:05 +08:00
luch
412fc75cfe
fix: 修改alioss.handler中单测文件的报错 (#105) 2024-05-09 21:27:57 +08:00
Weiguo Wang
60fdf14749
feat: vue3 (#103) 2024-05-09 20:34:24 +08:00
luch
8eb620ae7a
[Feature]: 补充file模块的单测 (#102)
* feat: 补充file模块的单测

* fix: 安装types依赖
2024-04-30 10:15:48 +08:00
chaorenluo
80614734f9
fix:前端项目使用nginx服务代理,不在使用后端服务代理 (#100)
* fix:前端项目使用nginx服务代理,不在使用后端服务代理

* fix:修改备注
2024-04-29 18:00:29 +08:00
luch
f43b7d72d4 feat: 新增file模块 (#101) 2024-04-25 15:30:01 +08:00
luch
d3f64707a0
feat: 新增file模块 (#101) 2024-04-25 13:44:36 +08:00
luch
be81e2a863 feat: 消息模块新增参数校验 (#95) 2024-04-09 11:52:28 +08:00
luch
a23fc28f5f
feat: 消息模块新增参数校验 (#95) 2024-04-09 11:46:49 +08:00
sudoooooo
c5489daac3
feat: Update README.md 2024-04-09 11:46:22 +08:00
chaorenluo
78f2a7418b fix: 修复评分和nps题型非必填提示没有填写的问题 (#94)
* fix: 修复评分和nps题型非必填提示没有填写的问题
* fix: 使用添加key的方法修复必填提示
2024-04-08 22:37:10 +08:00
chaorenluo
2930971da7
fix: 修复评分和nps题型非必填提示没有填写的问题 (#94)
* fix: 修复评分和nps题型非必填提示没有填写的问题
* fix: 使用添加key的方法修复必填提示
2024-04-08 17:28:02 +08:00
luch
dd1d977fbc feat: 调整字段必填,调整抽出fetch,修改单测 (#92) 2024-04-07 21:48:31 +08:00
luch
a357b49824 feat: 修改message单独一个module,修改默认json字段 (#90) 2024-04-07 21:48:20 +08:00
sudoooooo
84a3b6e6fa fix: 优化json 2024-04-07 21:48:10 +08:00
luch
9263ad9f2f
feat: 调整字段必填,调整抽出fetch,修改单测 (#92) 2024-04-07 21:27:44 +08:00
sudoooooo
0b3a8b57dd fix: 修复数据统计兼容展示处理和nps题型问题 2024-04-03 21:43:12 +08:00
sudoooooo
6d74d87ce2 fix: 修复数据统计兼容展示处理和nps题型问题 2024-04-03 21:41:31 +08:00
chaorenluo
a39f3ed3b1 fix: getListHeadByDataList函数里面的兼容radio-nps (#89) 2024-04-03 21:26:24 +08:00
dayou
c07fc4341d chore: 更新iconfont (#80) 2024-04-03 21:26:22 +08:00
chaorenluo
05b9416cd9 feat: nps评分 (#79)
* feat: nps评分功能

* feat: nps样式添加

* feat: 添加nps评分icon

* feat: 基于修改建议修改nps评分组件

* feat: 将自定义类移入至设置器
2024-04-03 21:26:18 +08:00
luch
08aaefc3e4
feat: 修改message单独一个module,修改默认json字段 (#90) 2024-04-03 19:04:24 +08:00
chaorenluo
b7a716778a
fix: getListHeadByDataList函数里面的兼容radio-nps (#89) 2024-04-03 11:22:43 +08:00
sudoooooo
071afbfec1 fix: 冲突修复 2024-04-01 22:49:57 +08:00
luch
746bece538 feat: 新增数据推送功能 (#86) 2024-04-01 22:32:17 +08:00
dayou
a6e88fa444
chore: 更新iconfont (#80) 2024-04-01 17:11:59 +08:00
chaorenluo
590e742cd7
feat: nps评分 (#79)
* feat: nps评分功能

* feat: nps样式添加

* feat: 添加nps评分icon

* feat: 基于修改建议修改nps评分组件

* feat: 将自定义类移入至设置器
2024-04-01 17:11:52 +08:00
luch
47a831b1ec
fix: 解决冲突 (#87) 2024-03-28 21:46:15 +08:00
luch
11df81eb08
feat: 新增数据推送功能 (#86) 2024-03-28 21:37:59 +08:00
sudoooooo
a9d8b7a860 Update README.md 2024-03-28 11:46:34 +08:00
sudoooooo
7b1582dde8 feat: Update README.md 2024-03-28 11:46:30 +08:00
sudoooooo
b3b5fa9fac
Update README.md 2024-03-28 10:49:31 +08:00
sudoooooo
ed56816836
feat: Update README.md 2024-03-28 10:49:05 +08:00
luch
d23924347e fix: 修改更新问卷基础信息的接口允许remark字段为空 (#84) 2024-03-27 15:48:49 +08:00
luch
24660cb6c0
fix: 修改更新问卷基础信息的接口允许remark字段为空 (#84) 2024-03-27 15:43:18 +08:00
luch
15a41f1ba7 fix: 修改创建问卷允许空字符串,参数错误抛出异常 (#83) 2024-03-27 14:57:42 +08:00
luch
6d692de753
fix: 修改创建问卷允许空字符串,参数错误抛出异常 (#83) 2024-03-27 14:45:25 +08:00
luch
39e89a4651
fix: 创建问卷remark允许空 (#82) 2024-03-27 14:13:57 +08:00
sudoooooo
5c99bd759e feat: 优化时间排序 2024-03-25 10:56:30 +08:00
dayou
968576b665 feat: 皮肤设置 (#78) 2024-03-25 10:56:25 +08:00
sudoooooo
c7fa38fefb feat: 优化时间排序 2024-03-25 10:55:58 +08:00
dayou
5f1bf1efad
feat: 皮肤设置 (#78) 2024-03-22 17:51:32 +08:00
sudoooooo
90243b9875 feat: 优化README 2024-03-20 14:18:39 +08:00
sudoooooo
e27cb68874 feat: 优化README 2024-03-20 14:17:45 +08:00
sudoooooo
f86eab9a95 feat: 修改pr模板 2024-03-18 19:37:25 +08:00
sudoooooo
78dbb0f615 feat: 修改pr模板 2024-03-18 19:37:03 +08:00
sudoooooo
4b860732c0 feat: 增加pr模板 2024-03-18 19:34:43 +08:00
sudoooooo
f831a2f174 feat: 增加pr模板 2024-03-18 19:34:03 +08:00
sudoooooo
456df124f1 feat: 修改readme&增加issue模板 2024-03-18 19:26:15 +08:00
sudoooooo
be6f3c5c7e feat: 修改readme&增加issue模板 2024-03-18 18:19:19 +08:00
dayou
565e02ee5c fix: 升级lodash-es (#73) 2024-03-18 11:08:36 +08:00
dayou
d78604ae90
fix: 升级lodash-es (#73) 2024-03-17 13:29:52 +08:00
luch
e4a8389cf5 feat: 完善单测 (#71) 2024-03-14 22:24:29 +08:00
luch
8d29865ff0
feat: 完善单测 (#71) 2024-03-14 21:50:57 +08:00
sudoooooo
c868ca91c2 feat: 修改readme 2024-03-07 17:53:51 +08:00
sudoooooo
292082e3b0 feat: 修改readme 2024-03-07 17:53:03 +08:00
chaorenluo
eea5418734 fix:修复h5模式下没填完进度条已满问题 (#69) 2024-03-07 15:53:42 +08:00
chaorenluo
70f5c45abe feat:新增进度条功能 (#68) 2024-03-07 15:53:36 +08:00
chaorenluo
3ef5e75cba fix:修复评分组件高级设置显示异常 (#63)
* fix:修复评分组件高级设置显示异常
---------

Co-authored-by: ljm <ljm@addcn.com>
2024-03-07 15:53:30 +08:00
chaorenluo
66cc671918
fix:修复h5模式下没填完进度条已满问题 (#69) 2024-03-07 13:52:55 +08:00
chaorenluo
98773bdbd6
feat:新增进度条功能 (#68) 2024-03-05 15:12:07 +08:00
chaorenluo
67d85f9ee1 fix:修复评分组件高级设置显示异常 (#63)
* fix:修复评分组件高级设置显示异常
---------

Co-authored-by: ljm <ljm@addcn.com>
2024-02-29 16:11:37 +08:00
luch
b8c089f3fd fix: 修改列表页筛选bug 2024-02-07 15:56:26 +08:00
sudoooooo
e5f70ad08c fix: 修复图片路径问题 2024-02-07 15:56:16 +08:00
sudoooooo
225e450bc9 feat: 修改traceid生成规则,修改readme、图片和windows兼容 2024-02-07 15:55:55 +08:00
luch
e3fa804fa8 fix: 修改返回页面路径问题 (#58) 2024-02-07 15:55:43 +08:00
luch
9390295f5b feat: 升级server到nestjs框架 2024-02-07 15:55:25 +08:00
luch
5415daf906 fix: 修改列表页筛选bug 2024-02-06 21:50:29 +08:00
sudoooooo
898becaf3d fix: 修复图片路径问题 2024-02-06 21:48:32 +08:00
sudoooooo
bdeae4abcd feat: 修改traceid生成规则,修改readme、图片和windows兼容 2024-02-06 21:36:19 +08:00
luch
cd1f416de0 fix: 修改返回页面路径问题 (#58) 2024-02-06 21:35:13 +08:00
luch
e6f1be290f feat: 升级server到nestjs框架 2024-02-06 21:32:34 +08:00
sudoooooo
501e38f082 fix: 修复已保存数据导致资源丢失问题 2024-02-05 16:39:08 +08:00
sudoooooo
dfdc8025e9 fix: 修复已保存数据导致资源丢失问题 2024-02-05 16:37:15 +08:00
Weiguo Wang
e1f91de9ef [performance]:压缩图片,优化编辑器图片懒加载 (#59)
* chore: 压缩图片,格式改为webp

* perf: 优化编辑器首次加载速度,图片懒加载

* perf: 固定高度,优化滑动
2024-02-01 21:38:57 +08:00
Weiguo Wang
152ce55dce
[performance]:压缩图片,优化编辑器图片懒加载 (#59)
* chore: 压缩图片,格式改为webp

* perf: 优化编辑器首次加载速度,图片懒加载

* perf: 固定高度,优化滑动
2024-02-01 21:33:34 +08:00
luch
203537a9ce fix: 修改排序接口联调bug (#53) 2024-01-23 21:53:24 +08:00
Realabiha
0778b68ef8 feat: 问卷列表新增排序功能 (#54) 2024-01-23 21:53:24 +08:00
luch1994
43186b86ef fix: 修改路由解析问题 2024-01-19 14:22:55 +08:00
Realabiha
e34e5a606b feat: 问卷列表新增类型和状态筛选 2024-01-18 17:22:48 +08:00
sudoooooo
c32bbd9124 feat: 优化配置管理和lint内容 2024-01-17 15:16:33 +08:00
alwayrun
5fff688612 feat: 调整服务端环境变量导入机制 (#43)
1、为环境变量 process.env 增加 TypeScript 类型检测与感知
2、增加 dotenv & @types/node 处理支持 .env文件环境变量导入机制
2024-01-17 10:32:39 +08:00
luch1994
f851b0df10 feat: 修改参数不正确报错 2024-01-16 15:34:18 +08:00
dayou
cf9f00abf5 feat:列表页新增搜索功能 (#46) 2024-01-16 15:33:38 +08:00
luch1994
620011a19a feat: 问卷列表接口新增搜索功能 2024-01-16 15:33:38 +08:00
sudoooooo
54adf69db5
feat: Update README.md 2024-01-15 18:02:31 +08:00
sudoooooo
d9e0bdfb9a fix: 修复存储加密变量不存在 2024-01-11 15:38:58 +08:00
dayou
31ddefba16 feat: 列表页新增复制功能 (#42) 2024-01-11 15:38:58 +08:00
luch
908537ea0b feat: 新增复制功能 (#40) 2024-01-11 15:38:58 +08:00
luch
83cfd57245 feat: 新增回收数据存储加密 (#37) 2024-01-04 15:28:17 +08:00
luch
08f3bb0578
[Feature]: 服务端新增eslint (#35)
1. 服务端新增eslint
2. 修复所有的lint问题
3. 优化部署,把build命令放到docker构建中
2023-12-28 15:31:33 +09:00
639 changed files with 39902 additions and 16482 deletions

20
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,20 @@
---
name: Bug report
about: 反馈遇到的问题 / 发现的Bug
title: ''
labels: 'Bug'
assignees: ''
---
### 分类
<!-- 前端\后\平台功能\环境 -->
### 系统
### 复现步骤
<!-- 描述具体内容,越详细越好,有截图更好 -->
### 预期结果
### 实际结果

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: 提出需求 / 优化 / 建议
title: ''
labels: 'feature'
assignees: ''
---
### 您的需求是否与某个问题有关?
<!-- 清晰描述问题比如当你使用xxx时总是有xxx问题希望xxxx -->
### 描述您希望的需求
<!-- 清晰描述需求,比如在什么场景使用/解决什么问题/满足什么功能/... -->
### 描述您考虑过的备选方案 / 建议
<!-- 清晰描述预期的方案 / 想法 -->
### 我想要认领此需求 / 优化
<!-- 如果想认领,可录入“确认认领” 以及 预计可完成时间,官方将在一周内给出方案链接通您进行沟通 -->

17
.github/ISSUE_TEMPLATE/pr_request.md vendored Normal file
View File

@ -0,0 +1,17 @@
---
name: PR request
about: 关于某个PR的详细描述
title: '[类型]: xxx'
labels: 'pr'
assignees: ''
---
### 改动原因
<!-- 清晰描述PR目的、原因 -->
### 改动内容
<!-- 详细描述改了什么,必要的话配截图 -->
### 改动验证
<!-- 如何验证的,必要的修改需要补充单测 -->

View File

@ -0,0 +1,10 @@
---
name: "\U0001F60A 抛个问题 / 想法 / 建议"
about: 提个项目相关的问题 / 想法 / 建议
title: ''
labels: ''
assignees: ''
---
<!-- 提出项目相关的任何问题、想法、建议或您想讨论的话题。或者只是想聊聊 -->

7
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,7 @@
<!-- 请严格遵循贡献规范https://xiaojusurvey.didi.cn/docs/next/share/%E8%B4%A1%E7%8C%AE%E6%B5%81%E7%A8%8B -->
### 改动内容
<!-- 不同功能拆分成多个PR。简洁记录改动内容用1、2、3...分序号描述改动点 -->
### Issue
<!-- 确保大的改动创建一个Issue描述详情https://github.com/didi/xiaoju-survey/issues/new?assignees=&labels=&projects=&template=pr_report.md&title=[类型]: xxx -->

44
.github/workflows/codecov.yml vendored Normal file
View File

@ -0,0 +1,44 @@
# Unit Test Coverage Report
name: Test Coverage
on:
push:
branches:
- main
- releases/**
paths:
- server/**
pull_request:
branches:
- develop
- main
- releases/**
- feature/**
paths:
- server/**
workflow_dispatch:
jobs:
build:
name: Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: cd server && npm install
- name: Run tests and collect coverage
run: cd server && npm run test:cov
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

39
.github/workflows/server-lint.yml vendored Normal file
View File

@ -0,0 +1,39 @@
# Lint
name: Server Lint
on:
push:
branches:
- main
- releases/**
paths:
- server/**
pull_request:
branches:
- develop
- main
- releases/**
- feature/**
paths:
- server/**
workflow_dispatch:
jobs:
build:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: cd server && npm install
- name: Lint
run: cd server && npm run lint

42
.github/workflows/web-lint.yml vendored Normal file
View File

@ -0,0 +1,42 @@
# Lint
name: Web Lint
on:
push:
branches:
- main
- releases/**
paths:
- web/**
pull_request:
branches:
- develop
- main
- releases/**
- feature/**
paths:
- web/**
workflow_dispatch:
jobs:
build:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: cd web && npm install
- name: Type-Check
run: cd web && npm run type-check
- name: Lint
run: cd web && npm run lint

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.

View File

@ -1,5 +1,5 @@
# 镜像集成
FROM node:16
FROM node:18-slim
# 设置工作区间
WORKDIR /xiaoju-survey
@ -7,17 +7,21 @@ WORKDIR /xiaoju-survey
# 复制文件到工作区间
COPY . /xiaoju-survey
# 安装nginx
RUN apt-get update && apt-get install -y nginx
RUN npm config set registry https://registry.npmjs.org/
# 安装项目依赖
RUN cd /xiaoju-survey/web && npm install && npm run build
# 用了后端服务代理启动建议使用nginx启动
RUN cd /xiaoju-survey && cp -af ./web/dist/* ./server/public/
RUN cd /xiaoju-survey/web && npm install && npm run build-only
# 覆盖nginx配置文件
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
RUN cd /xiaoju-survey/server && npm install && npm run build
# 暴露端口 需要跟server的port一致
EXPOSE 3000
# 暴露端口 需要跟nginx的port一致
EXPOSE 80
# docker入口文件,运行pm2启动,并保证监听不断
# docker入口文件,启动nginx和运行pm2启动,并保证监听不断
CMD ["sh","docker-run.sh"]

View File

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright (C) 2023 Beijing Didi Infinity Technology and Development Co.,Ltd. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

176
README.md
View File

@ -4,182 +4,174 @@
</p>
<div>
<a href="https://github.com/didi/xiaoju-survey/graphs/contributors">
<img src="https://img.shields.io/github/last-commit/didi/xiaoju-survey?color=red" alt="commit">
<img src="https://img.shields.io/badge/node-%3E=18-green" alt="commit">
</a>
<a href="https://github.com/didi/xiaoju-survey/pulls">
<img src="https://img.shields.io/badge/PRs-welcome-%23ffa600" alt="pr">
<a href="https://app.codecov.io/github/didi/xiaoju-survey">
<img src="https://img.shields.io/codecov/c/github/didi/xiaoju-survey" alt="codecov">
</a>
<a href="https://github.com/didi/xiaoju-survey/issues">
<img src="https://img.shields.io/github/issues/didi/xiaoju-survey" alt="issues">
<img src="https://img.shields.io/github/issues/didi/xiaoju-survey" alt="issues">
</a>
<a href="https://github.com/didi/xiaoju-survey/discussions">
<img src="https://img.shields.io/badge/Discussions-%E8%AE%A8%E8%AE%BA-blue" alt="discussions-">
<a href="https://github.com/didi/xiaoju-survey/graphs/contributors">
<img src="https://img.shields.io/github/last-commit/didi/xiaoju-survey?color=red" alt="commit">
</a>
<a href="https://github.com/didi/xiaoju-survey/pulls">
<img src="https://img.shields.io/badge/PRs-welcome-%23ffa600" alt="pr">
</a>
<a href="https://xiaojusurvey.didi.cn">
<img src="https://img.shields.io/badge/help-%E6%96%87%E6%A1%A3-blue" alt="docs">
<img src="https://img.shields.io/badge/help-%E5%AE%98%E7%BD%91-blue" alt="docs">
</a>
<a href="https://github.com/didi/xiaoju-survey/blob/main/README_EN.md">
<img src="https://img.shields.io/badge/help-README_EN-50c62a" alt="docs">
</a>
</div>
</div>
<br />
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的**问卷系统**,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
&ensp;&ensp;**XIAOJUSURVEY**是一套轻量、安全的调研系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
&ensp;&ensp;系统已沉淀40+种题型累积精选模板100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
&ensp;&ensp;内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
# 简介
本次开源主要围绕问卷生命周期提供了完整的产品化能力:
# 功能特性
- 问卷管理:创、编、投、收、数据分析
**🌈 易用**
- 多样化题型:单行输入框、多行输入框、单项选择、多项选择、判断题、评分、投票
- 多类型数据采集,轻松创建调研表单:文本输入、数据选择、评分、投票、文件上传等。
_(更多题型将陆续开放。快速[自定义题型](https://xiaojusurvey.didi.cn/docs/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E9%A2%98%E5%9E%8B%E6%89%A9%E5%B1%95))_
- 智能逻辑编排,设计多规则动态表单:显示逻辑、跳转逻辑、选项引用、题目引用等。
- 用户管理:登录、注册、权限管理
- 精细权限管理,支持高效团队协同:空间管理、多角色权限管理等。
- 数据安全:传输加密、脱敏等
- 数据在线分析和导出,洞察调研结果:数据导出、回收数据管理、分题统计、交叉分析等。
> 查阅[官方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端Vue2Vue3版本24年上半年推出+ ElementUI
Server端Nestjs + MongoDB
**1、Web 端Vue3 + ElementPlus**
架构:[架构解读](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E6%9E%B6%E6%9E%84)
&ensp;&ensp;C 端多端渲染ReactNative SDK 建设中
**2、Server 端NestJS + MongoDB**
&ensp;&ensp;Java 版:建设中,[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)
**3、能力增强**
&ensp;&ensp;在线平台:建设中、智能化问卷:规划中
# 项目优势
**一、具备全面的综合性和专业性**
- [制定了问卷标准化协议规范](https://xiaojusurvey.didi.cn/docs/agreement/%E3%80%8A%E9%97%AE%E5%8D%B7Meta%E5%8D%8F%E8%AE%AE%E3%80%8B)
- [制定了问卷标准化协议规范](https://xiaojusurvey.didi.cn/docs/next/agreement/%E3%80%8A%E9%97%AE%E5%8D%B7Meta%E5%8D%8F%E8%AE%AE%E3%80%8B)
领域标准保障概念互通,是全系统的基础和核心。基于实际业务经验,沉淀了两大类:
- 业务描述:问卷协议、题型协议
- 物料描述:题型物料协议,包含题型和设置器
- [制定了问卷UI/UX规范](https://xiaojusurvey.didi.cn/docs/design/%E3%80%8A%E8%AE%BE%E8%AE%A1%E8%A7%84%E8%8C%83%E3%80%8B)
- [制定了问卷 UI/UX 规范](https://xiaojusurvey.didi.cn/docs/next/design/%E3%80%8A%E8%AE%BE%E8%AE%A1%E8%A7%84%E8%8C%83%E3%80%8B)
设计语言是系统灵活性、一致性的基石,保障系统支撑的实际业务运转拥有极高的用户体验。包含两部分:
- 设计规范:灵活、降噪、统一
- 交互规范:遵循用户行为特征,遵循产品定位,遵循成熟的用户习惯
- [所见即所得,搭建渲染一致性高](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E5%9C%BA%E6%99%AF%E5%8C%96%E8%AE%BE%E8%AE%A1)
- [所见即所得,搭建渲染一致性高](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E5%9C%BA%E6%99%AF%E5%8C%96%E8%AE%BE%E8%AE%A1)
实际业务使用上包含问卷生成和投放使用,即对于系统的搭建端和渲染端。我们将题型场景化设计,以满足一份问卷从加工生产到投放应用的高度一致。
- [题型物料化设计,自由定制扩展](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E7%89%A9%E6%96%99%E5%8C%96%E8%AE%BE%E8%AE%A1/%E5%9F%BA%E7%A1%80%E8%AE%BE%E8%AE%A1)
- [题型物料化设计,自由定制扩展](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E7%89%A9%E6%96%99%E5%8C%96%E8%AE%BE%E8%AE%A1/%E5%9F%BA%E7%A1%80%E8%AE%BE%E8%AE%A1)
题型是问卷最核心的组成部分,而题型可配置化能力决定了上层业务可扩展的场景以及系统自身可复用的场景。
题型架构设计上,主打每一类题型拥有通用基础能力,每一种题型拥有原子化特性能力,并保障高度定制化。
题型架构设计上,主打每一类题型拥有通用基础能力,每一种题型拥有原子化特性能力,并保障高度定制化。
- [合规建设沉淀积累,安全能力拓展性高](https://xiaojusurvey.didi.cn/docs/document/%E6%95%B0%E6%8D%AE%E5%AE%89%E5%85%A8)
- [合规建设沉淀积累,安全能力拓展性高](https://xiaojusurvey.didi.cn/docs/next/document/%E5%AE%89%E5%85%A8%E8%AE%BE%E8%AE%A1/%E6%A6%82%E8%BF%B0)
数据加密传输、敏感信息精细化检测、投票防刷等能力,保障问卷发布、数据回收链路安全性。
**二、轻量化设计,快速接入、灵活扩展**
- [产品级开源方案,快速产出一套调研流程](https://xiaojusurvey.didi.cn/docs/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E6%A6%82%E8%BF%B0)
- [产品级开源方案,快速产出一套调研流程](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E6%A6%82%E8%BF%B0)
围绕问卷生命周期提供了完整的产品化能力,包含用户管理: 登录、注册、问卷权限,问卷管理: 创、编、投、收、数据分析,可快速构建特定领域的调研类解决方案。
- [问卷设计开箱即用,降低领域复杂度](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%97%AE%E5%8D%B7%E6%90%AD%E5%BB%BA%E9%A2%86%E5%9F%9F%E5%8C%96%E8%AE%BE%E8%AE%A1)
- [问卷设计开箱即用,降低领域复杂度](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%97%AE%E5%8D%B7%E6%90%AD%E5%BB%BA%E9%A2%86%E5%9F%9F%E5%8C%96%E8%AE%BE%E8%AE%A1)
问卷组成具有高灵活性,此业务特征带来问卷编辑能力的高复杂性设计。我们将问卷编辑划分为五大子领域,进行产品能力聚类,同时指导系统模块化设计和开发。基于模块编排和管理,能够开箱即用。
- [二次开发成本低,轻松定制专属调研系统](https://xiaojusurvey.didi.cn/docs/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E5%B7%A5%E7%A8%8B%E9%85%8D%E7%BD%AE%E5%8C%96)
- [二次开发成本低,轻松定制专属调研系统](https://xiaojusurvey.didi.cn/docs/next/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E5%B7%A5%E7%A8%8B%E9%85%8D%E7%BD%AE%E5%8C%96)
全系统设计原则基于协议标准化、功能模块化、管理配置化,并提供了一些列完整的文档和开发及扩展手册。
- [部署成本低,快速上线](https://xiaojusurvey.didi.cn/docs/document/%E5%B7%A5%E7%A8%8B%E9%83%A8%E7%BD%B2)
- [部署成本低,快速上线](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)
前后端分离提供Docker化方案提供了完善的部署指导手册。
前后端分离,提供 Docker 化方案,提供了完善的部署指导手册。
# 快速启动
# 快速使用
Node版本 >= 16.x
[查看环境准备指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
_在线平台建设中_
复制工程
```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) 来启动项目。
### 方案一、快速启动,无需安装数据库
> _便于快速预览工程对于正式项目需要使用方案二。_
# 快速部署
#### 1、安装依赖
```shell
cd server
npm install
```
### 服务部署
#### 2、启动
```shell
npm run local
```
请查看 [快速部署指导](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) 。
> 服务运行依赖 [mongodb-memory-server](https://github.com/nodkz/mongodb-memory-server)
>
> 1、数据保存在内存中重启服务会更新数据。<br />2、启动内存服务器新实例时如果找不到MongoDB二进制文件会自动下载因此首次可能需要一些时间。
### 一键部署
### 方案二、(生产推荐)
_手册编写中_
#### 1、启动数据库
<br />
> 项目使用MongoDB[MongoDB安装指导](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83)
## Star
启动和配置数据库,查看[MongoDB启动](http://localhost:5000/docs/next/document/%E6%A6%82%E8%BF%B0/%E5%AE%89%E8%A3%85%E7%8E%AF%E5%A2%83#%E4%BA%94%E5%90%AF%E5%8A%A8)
开源不易,如果该项目对你有帮助,请 star 一下 ❤️❤️❤️,你的支持是我们最大的动力。
#### 2、安装依赖
```shell
cd server
npm install
```
[![Star History Chart](https://api.star-history.com/svg?repos=didi/xiaoju-survey&type=Date)](https://star-history.com/#didi/xiaoju-survey&Date)
#### 3、启动
```shell
npm run dev
```
## 交流群
## 前端启动
### 安装依赖
```shell
cd web
npm install
```
### 启动
```shell
npm run serve
```
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。
## 访问
### 问卷管理端
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
[http://localhost:8080/management](http://localhost:8080)
_任何问题和合作可以联系小助手。_
### 问卷投放端
创建并发布问卷。
## 案例
[http://localhost:8080/render/:surveyPath](http://localhost:8080/render/:surveyPath)
如果你使用了该项目,请记录反馈:[我在使用](https://github.com/didi/xiaoju-survey/issues/64),你的支持是我们最大的动力。
## Future Tasks
# 交流群
## 微信
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="300" />
[欢迎了解项目发展和共建](https://github.com/didi/xiaoju-survey/issues/85),你的支持是我们最大的动力。
## QQ
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/iJUmLIHKV21700192846057.png" width="300" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)
## 贡献
# Feature
[官方Feature](https://github.com/didi/xiaoju-survey/issues/45)
如果你想成为贡献者或者扩展技术栈,请查看:[贡献者指南](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),你的加入使我们最大的荣幸。
# CHANGELOG
[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)
## CHANGELOG
关注项目重大变更:[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)。

237
README_EN.md Normal file
View File

@ -0,0 +1,237 @@
<div align=center>
<p>
<img src="https://img-hxy021.didistatic.com/static/starimg/img/j8lBA6yy201698840712358.jpg" width="300" align='center' />
</p>
<div>
<a href="https://github.com/didi/xiaoju-survey/graphs/contributors">
<img src="https://img.shields.io/badge/node-%3E=18-green" alt="commit">
</a>
<a href="https://app.codecov.io/github/didi/xiaoju-survey">
<img src="https://img.shields.io/codecov/c/github/didi/xiaoju-survey" alt="codecov">
</a>
<a href="https://github.com/didi/xiaoju-survey/issues">
<img src="https://img.shields.io/github/issues/didi/xiaoju-survey" alt="issues">
</a>
<a href="https://github.com/didi/xiaoju-survey/graphs/contributors">
<img src="https://img.shields.io/github/last-commit/didi/xiaoju-survey?color=red" alt="commit">
</a>
<a href="https://github.com/didi/xiaoju-survey/pulls">
<img src="https://img.shields.io/badge/PRs-welcome-%23ffa600" alt="pr">
</a>
<a href="https://xiaojusurvey.didi.cn">
<img src="https://img.shields.io/badge/help-website-blue" alt="docs">
</a>
<a href="https://github.com/didi/xiaoju-survey/blob/main/README.md">
<img src="https://img.shields.io/badge/help-README_ZH-50c62a" alt="docs">
</a>
</div>
</div>
<br />
&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.
# Features
**🌈 Easy to use**
- Multi-type data collection, easy to create forms: text input, data selection, scoring, voting, file upload, etc.
- Smart logic arrangement, design multi-rule dynamic forms: display logic, jump logic, option reference, title reference, etc.
- Multiple permission management, support efficient team collaboration: space management, multi-role permission management, etc.
- Online data analysis and export, insight into survey results: data export, recycled data management, sub-topic statistics, cross-analysis, etc.
**🎨 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" />
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)).
Online Platform: (under construction).
Intelligent Foundation: (planning).
# Project Advantages
**1. Comprehensive and Professional**
- [Standardized Protocols for Questionnaires](https://xiaojusurvey.didi.cn/docs/next/agreement/%E3%80%8A%E9%97%AE%E5%8D%B7Meta%E5%8D%8F%E8%AE%AE%E3%80%8B)
Ensuring concept interoperability is the foundation and core of the entire system. Based on practical business experience, two main categories have been established:
Business Descriptions: Questionnaire protocol, question type protocol.
Material Descriptions: Question type material protocol, including question types and settings.
- [UI/UX Standardization for Questionnaires](https://xiaojusurvey.didi.cn/docs/next/design/%E3%80%8A%E8%AE%BE%E8%AE%A1%E8%A7%84%E8%8C%83%E3%80%8B)
The design language is the cornerstone of system flexibility and consistency, ensuring the system supports actual business operations with high user experience. It includes:
Design Standards: Flexible, noise-reducing, unified.
Interaction Standards: Follows user behavior characteristics, product positioning, and mature user habits.
- [WYSIWYG with High Consistency in Construction and Rendering](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E5%9C%BA%E6%99%AF%E5%8C%96%E8%AE%BE%E8%AE%A1)
In practical business usage, the system includes both questionnaire generation and deployment. We design question types in a scenario-based manner to ensure high consistency from production to application.
- [Materialized Question Type Design for Free Customization and Extension](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E7%89%A9%E6%96%99%E5%8C%96%E8%AE%BE%E8%AE%A1/%E5%9F%BA%E7%A1%80%E8%AE%BE%E8%AE%A1)
The core component of questionnaires is question types, and their configurability determines the extensibility of business scenarios and the system's reusability. Each question type has general capabilities and atomic characteristics, ensuring high customization.
- [Compliance Accumulation and High Expandability in Security Capabilities](https://xiaojusurvey.didi.cn/docs/next/document/%E5%AE%89%E5%85%A8%E8%AE%BE%E8%AE%A1/%E6%A6%82%E8%BF%B0)
Data encryption, sensitive information detection, and anti-vote brushing capabilities ensure the security of the questionnaire publishing and data collection process.
Lightweight Design for Quick Integration and Flexible Expansion.
**2. Product-Level Open-Source Solution for Rapid Survey Process Implementation**
- [Product-Level Open-Source Solution for Rapid Survey Process Implementation](https://xiaojusurvey.didi.cn/docs/next/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E6%A6%82%E8%BF%B0)
Provides complete product capabilities around the questionnaire lifecycle, including user management (login, registration, questionnaire permissions) and questionnaire management (create, edit, distribute, collect, data analysis), allowing for quick construction of survey solutions in specific fields.
- [Out-of-the-Box Questionnaire Design to Reduce Domain Complexity](https://xiaojusurvey.didi.cn/docs/next/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%97%AE%E5%8D%B7%E6%90%AD%E5%BB%BA%E9%A2%86%E5%9F%9F%E5%8C%96%E8%AE%BE%E8%AE%A1)
High flexibility in questionnaire composition leads to high complexity in editing capabilities. We divide questionnaire editing into five sub-domains for product capability clustering and guide system modular design and development. Based on module arrangement and management, it can be used out-of-the-box.
- [Low Cost for Secondary Development and Easy Customization](https://xiaojusurvey.didi.cn/docs/next/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E5%B7%A5%E7%A8%8B%E9%85%8D%E7%BD%AE%E5%8C%96)
The entire system is designed based on protocol standardization, function modularization, and management configuration, and provides a complete set of documentation and development and extension manuals.
- [Low Deployment Cost for Quick Online Launch](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)
The front-end and back-end separation, Dockerization solutions, and complete deployment guidance manual.
# Quick Start
Node Version >= 18.x, [ check environment preparation guide.](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)
Clone Project
```shell
git clone git@github.com:didi/xiaoju-survey.git
```
## Server Startup
### Option 1: Quick Start without Database Installation
> _This is convenient for quickly previewing the project. For formal projects, use Option 2._
#### 1.Install Dependencies
```shell
cd server
npm install
```
#### 2.Start
```shell
npm run local
```
> The service relies on mongodb-memory-server:
> 1.Data is stored in memory and will be updated upon service restart.
> 2.When starting a new instance of the memory server, it will automatically download MongoDB binaries if not found, which might take some time initially.
### Option 2: (Recommended for Production)
#### 1.Configure Database
> The project uses MongoDB: [MongoDB Guide](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E6%95%B0%E6%8D%AE%E5%BA%93#%E5%AE%89%E8%A3%85)
Configure the database, check MongoDB configuration.
#### 2.Install Dependencies
```shell
cd server
npm install
```
#### 2.Install Dependencies
```shell
npm run dev
```
## Frontend Startup
### Install Dependencies
```shell
cd web
npm install
```
### Start
```shell
npm run serve
```
## Access
### Questionnaire Management End
[http://localhost:8080/management](http://localhost:8080)
### Questionnaire Deployment End
Create and publish a questionnaire.
[http://localhost:8080/render/:surveyPath](http://localhost:8080/render/:surveyPath)
<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" />
## 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.
## Contribution
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.
## Future Tasks
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)

View File

@ -15,11 +15,11 @@ services:
- xiaoju-survey
xiaoju-survey:
image: "xiaojusurvey/xiaoju-survey:1.0.3"
image: "xiaojusurvey/xiaoju-survey:1.3.0-slim" # 最新版本https://hub.docker.com/r/xiaojusurvey/xiaoju-survey/tags
container_name: xiaoju-survey
restart: always
ports:
- "8080:3000" # API端口
- "8080:80" # API端口
environment:
XIAOJU_SURVEY_MONGO_URL: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@xiaoju-survey-mongo:27017 # docker-compose 会根据容器名称自动处理
links:

View File

@ -1,3 +1,9 @@
#! /bin/bash
# 启动nginx
echo 'nginx start'
nginx -g 'daemon on;'
# 启动后端服务
cd /xiaoju-survey/server
npm run start:prod

68
nginx/nginx.conf Normal file
View File

@ -0,0 +1,68 @@
# 启动的 worker 进程数量
worker_processes auto;
# 错误日志路径和级别
error_log /var/log/nginx/error.log warn;
events {
# 最大连接数
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
# IPv6端口
listen [::]:80;
server_name localhost;
# gzip config
gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
root /xiaoju-survey/web/dist;
location / {
try_files $uri $uri /management.html;
}
location /management/ {
try_files $uri $uri/ /management.html;
}
location /management/preview/ {
try_files $uri $uri/ /render.html;
}
location /render/ {
try_files $uri $uri/ /render.html;
}
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,6 +1,12 @@
XIAOJU_SURVEY_MONGO_DB_NAME=xiaojuSurvey
XIAOJU_SURVEY_MONGO_URL=mongodb://localhost:27017
XIAOJU_SURVEY_MONGO_AUTH_SOURCE=
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
@ -9,4 +15,4 @@ 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_LOGGER_FILENAME=./logs/app.log

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

7
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
@ -35,4 +36,8 @@ lerna-debug.log*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/extensions.json
tmp
exportfile
userUpload

View File

@ -1,11 +1,11 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"name": "xiaoju-survey-server",
"version": "1.3.0",
"description": "XIAOJUSURVEY的server端",
"author": "",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"src/**/__test/*.ts\"",
"local": "ts-node ./scripts/run-local.ts",
"start": "nest start",
"dev": "npm run start:dev",
@ -22,20 +22,30 @@
"@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",
"cheerio": "^1.0.0-rc.12",
"ali-oss": "^6.20.0",
"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",
"log4js": "^6.9.1",
"minio": "^7.1.3",
"moment": "^2.30.1",
"mongodb": "^5.9.2",
"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",
@ -45,13 +55,18 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/ali-oss": "^6.16.11",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash": "^4.17.0",
"@types/multer": "^1.4.11",
"@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",
@ -59,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": [
@ -79,12 +95,18 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"**/*.(t|j)s",
"!**/*.module.ts",
"!**/upgrade.*.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
}
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.6.0"
}
}

View File

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

View File

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

View File

@ -5,11 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷投放端</title>
<link rel="stylesheet" href="./commom.css">
<link rel="stylesheet" href="/commom.css">
</head>
<body>
<div id="main">
<img src="./nodata.png" alt="">
<img src="/nodata.png" alt="">
<p class="title">暂无数据</p>
</div>
</body>

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

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
describe('AppController', () => {
let controller: AppController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AppController],
}).compile();
controller = module.get<AppController>(AppController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -11,6 +11,10 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { SurveyModule } from './modules/survey/survey.module';
import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.module';
import { AuthModule } from './modules/auth/auth.module';
import { 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';
@ -27,17 +31,26 @@ import { Counter } from './models/counter.entity';
import { SurveyResponse } from './models/surveyResponse.entity';
import { ClientEncrypt } from './models/clientEncrypt.entity';
import { Word } from './models/word.entity';
import { MessagePushingTask } from './models/messagePushingTask.entity';
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],
@ -46,7 +59,7 @@ import { Logger } from './logger';
const authSource =
(await configService.get<string>(
'XIAOJU_SURVEY_MONGO_AUTH_SOURCE',
)) || '';
)) || 'admin';
const database = await configService.get<string>(
'XIAOJU_SURVEY_MONGO_DB_NAME',
);
@ -69,6 +82,13 @@ import { Logger } from './logger';
ResponseSchema,
ClientEncrypt,
Word,
MessagePushingTask,
MessagePushingLog,
Workspace,
WorkspaceMember,
Collaborator,
DownloadTask,
Session,
],
};
},
@ -76,9 +96,19 @@ import { Logger } from './logger';
AuthModule,
SurveyModule,
SurveyResponseModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
ServeStaticModule.forRootAsync({
useFactory: async () => {
return [
{
rootPath: join(__dirname, '..', 'public'),
},
];
},
}),
MessageModule,
FileModule,
WorkspaceModule,
UpgradeModule,
],
controllers: [AppController],
providers: [
@ -93,8 +123,7 @@ import { Logger } from './logger';
export class AppModule {
constructor(
private readonly configService: ConfigService,
private readonly pluginManager: XiaojuSurveyPluginManager,
private readonly logger: Logger,
private readonly pluginManager: PluginManager,
) {}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LogRequestMiddleware).forRoutes('*');
@ -108,7 +137,7 @@ export class AppModule {
),
new SurveyUtilPlugin(),
);
this.logger.init({
Logger.init({
filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'),
});
}

View File

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

View File

@ -1,18 +1,27 @@
export enum EXCEPTION_CODE {
AUTHTIFICATION_FAILED = 1001, // 没有权限
AUTHENTICATION_FAILED = 1001, // 未授权
PARAMETER_ERROR = 1002, // 参数有误
NO_PERMISSION = 1003, // 没有操作权限
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,7 @@
export enum MESSAGE_PUSHING_TYPE {
HTTP = 'http',
}
export enum MESSAGE_PUSHING_HOOK {
RESPONSE_INSERTED = 'response_inserted',
}

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,20 @@
export enum SURVEY_PERMISSION {
SURVEY_CONF_MANAGE = 'SURVEY_CONF_MANAGE',
SURVEY_RESPONSE_MANAGE = 'SURVEY_RESPONSE_MANAGE',
SURVEY_COOPERATION_MANAGE = 'SURVEY_COOPERATION_MANAGE',
}
export const SURVEY_PERMISSION_DESCRIPTION = {
SURVEY_CONF_MANAGE: {
name: '问卷配置管理',
value: SURVEY_PERMISSION.SURVEY_CONF_MANAGE,
},
surveyResponseManage: {
name: '问卷分析管理',
value: SURVEY_PERMISSION.SURVEY_RESPONSE_MANAGE,
},
surveyCooperatorManage: {
name: '协作者管理',
value: SURVEY_PERMISSION.SURVEY_COOPERATION_MANAGE,
},
};

View File

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

View File

@ -0,0 +1,41 @@
export enum ROLE {
ADMIN = 'admin',
USER = 'user',
}
export const ROLE_DESCRIPTION = {
ADMIN: {
name: '管理员',
value: ROLE.ADMIN,
},
USER: {
name: '用户',
value: ROLE.USER,
},
};
export enum PERMISSION {
READ_WORKSPACE = 'READ_WORKSPACE',
WRITE_WORKSPACE = 'WRITE_WORKSPACE',
READ_MEMBER = 'READ_MEMBER',
WRITE_MEMBER = 'WRITE_MEMBER',
READ_SURVEY = 'READ_SURVEY',
WRITE_SURVEY = 'WRITE_SURVEY',
}
export const ROLE_PERMISSION: Record<ROLE, PERMISSION[]> = {
[ROLE.ADMIN]: [
PERMISSION.READ_WORKSPACE,
PERMISSION.WRITE_WORKSPACE,
PERMISSION.READ_MEMBER,
PERMISSION.WRITE_MEMBER,
PERMISSION.READ_SURVEY,
PERMISSION.WRITE_SURVEY,
],
[ROLE.USER]: [
PERMISSION.READ_WORKSPACE,
PERMISSION.READ_MEMBER,
PERMISSION.READ_SURVEY,
PERMISSION.WRITE_SURVEY,
],
};

View File

@ -0,0 +1,55 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HttpExceptionsFilter } from '../httpExceptions.filter';
import { ArgumentsHost } from '@nestjs/common';
import { HttpException } from '../httpException';
import { Response } from 'express';
describe('HttpExceptionsFilter', () => {
let filter: HttpExceptionsFilter;
let mockArgumentsHost: ArgumentsHost;
let mockResponse: Partial<Response>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HttpExceptionsFilter],
}).compile();
filter = module.get<HttpExceptionsFilter>(HttpExceptionsFilter);
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockArgumentsHost = {
switchToHttp: jest.fn().mockReturnThis(),
getResponse: jest.fn().mockReturnValue(mockResponse),
} as unknown as ArgumentsHost;
});
it('should return 500 status and "Internal Server Error" message for generic errors', () => {
const genericError = new Error('Some error');
filter.catch(genericError, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
message: 'Internal Server Error',
code: 500,
errmsg: 'Some error',
});
});
it('should return 200 status and specific message for HttpException', () => {
const httpException = new HttpException('Specific error message', 1001);
filter.catch(httpException, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.json).toHaveBeenCalledWith({
message: 'Specific error message',
code: 1001,
errmsg: 'Specific error message',
});
});
});

View File

@ -1,7 +1,7 @@
import { HttpException } from './httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
export class AuthtificationException extends HttpException {
export class AuthenticationException extends HttpException {
constructor(public readonly message: string) {
super(message, EXCEPTION_CODE.AUTHTIFICATION_FAILED);
super(message, EXCEPTION_CODE.AUTHENTICATION_FAILED);
}
}

View File

@ -1,4 +1,3 @@
// all-exceptions.filter.ts
import {
ExceptionFilter,
Catch,

View File

@ -1,8 +1,8 @@
import { HttpException } from './httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
export class NoSurveyPermissionException extends HttpException {
export class NoPermissionException extends HttpException {
constructor(public readonly message: string) {
super(message, EXCEPTION_CODE.NO_SURVEY_PERMISSION);
super(message, EXCEPTION_CODE.NO_PERMISSION);
}
}

View File

@ -0,0 +1,100 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { Authentication } from '../authentication.guard';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { AuthenticationException } from 'src/exceptions/authException';
import { User } from 'src/models/user.entity';
jest.mock('jsonwebtoken');
describe('Authentication', () => {
let guard: Authentication;
let authService: AuthService;
let configService: ConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
Authentication,
{
provide: AuthService,
useValue: {
verifyToken: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
guard = module.get<Authentication>(Authentication);
authService = module.get<AuthService>(AuthService);
configService = module.get<ConfigService>(ConfigService);
});
it('should throw exception if token is not provided', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {},
}),
}),
};
await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthenticationException,
);
});
it('should throw exception if token is invalid', async () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
authorization: 'Bearer invalidToken',
},
}),
}),
};
jest
.spyOn(authService, 'verifyToken')
.mockRejectedValue(new Error('token is invalid'));
jest
.spyOn(configService, 'get')
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthenticationException,
);
});
it('should set user in request object and return true if user exists', async () => {
const request = {
headers: {
authorization: 'Bearer validToken',
},
};
const context = {
switchToHttp: () => ({
getRequest: () => request,
}),
};
const fakeUser = { username: 'testUser' } as User;
jest
.spyOn(configService, 'get')
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
jest.spyOn(authService, 'verifyToken').mockResolvedValue(fakeUser);
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(request['user']).toEqual(fakeUser);
});
});

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,198 @@
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext } from '@nestjs/common';
import { SurveyGuard } from '../survey.guard';
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';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
import { SurveyMeta } from 'src/models/surveyMeta.entity';
import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { Collaborator } from 'src/models/collaborator.entity';
import { SURVEY_PERMISSION } from 'src/enums/surveyPermission';
describe('SurveyGuard', () => {
let guard: SurveyGuard;
let reflector: Reflector;
let collaboratorService: CollaboratorService;
let surveyMetaService: SurveyMetaService;
let workspaceMemberService: WorkspaceMemberService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SurveyGuard,
{
provide: Reflector,
useValue: {
get: jest.fn(),
},
},
{
provide: CollaboratorService,
useValue: {
getCollaborator: jest.fn(),
},
},
{
provide: SurveyMetaService,
useValue: {
getSurveyById: jest.fn(),
},
},
{
provide: WorkspaceMemberService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
guard = module.get<SurveyGuard>(SurveyGuard);
reflector = module.get<Reflector>(Reflector);
collaboratorService = module.get<CollaboratorService>(CollaboratorService);
surveyMetaService = module.get<SurveyMetaService>(SurveyMetaService);
workspaceMemberService = module.get<WorkspaceMemberService>(
WorkspaceMemberService,
);
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
it('should allow access if no surveyId is present', async () => {
const context = createMockExecutionContext();
jest.spyOn(reflector, 'get').mockReturnValue(null);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw SurveyNotFoundException if survey does not exist', async () => {
const context = createMockExecutionContext();
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest.spyOn(surveyMetaService, 'getSurveyById').mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
SurveyNotFoundException,
);
});
it('should allow access if user is the owner of the survey by ownerId', async () => {
const context = createMockExecutionContext();
const surveyMeta = { ownerId: 'testUserId', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should allow access if user is the owner of the survey by username', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'testUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should allow access if user is a workspace member', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest
.spyOn(workspaceMemberService, 'findOne')
.mockResolvedValue({} as WorkspaceMember);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw NoPermissionException if user is not a workspace member', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: 'workspaceId' };
jest.spyOn(reflector, 'get').mockReturnValue('params.surveyId');
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should throw NoPermissionException if no permissions are provided', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
jest.spyOn(reflector, 'get').mockReturnValueOnce(null);
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should throw NoPermissionException if user has no matching permissions', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
jest.spyOn(reflector, 'get').mockReturnValueOnce(['requiredPermission']);
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest
.spyOn(collaboratorService, 'getCollaborator')
.mockResolvedValue({ permissions: [] } as Collaborator);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should allow access if user has the required permissions', async () => {
const context = createMockExecutionContext();
const surveyMeta = { owner: 'anotherUser', workspaceId: null };
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.surveyId');
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([SURVEY_PERMISSION.SURVEY_CONF_MANAGE]);
jest
.spyOn(surveyMetaService, 'getSurveyById')
.mockResolvedValue(surveyMeta as SurveyMeta);
jest.spyOn(collaboratorService, 'getCollaborator').mockResolvedValue({
permissions: [SURVEY_PERMISSION.SURVEY_CONF_MANAGE],
} as Collaborator);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
function createMockExecutionContext(): ExecutionContext {
return {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { username: 'testUser', _id: 'testUserId' },
params: { surveyId: 'surveyId' },
}),
}),
getHandler: jest.fn(),
} as unknown as ExecutionContext;
}
});

View File

@ -0,0 +1,137 @@
import { Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext } from '@nestjs/common';
import { WorkspaceGuard } from '../workspace.guard';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { NoPermissionException } from '../../exceptions/noPermissionException';
import { WorkspaceMember } from 'src/models/workspaceMember.entity';
import { PERMISSION as WORKSPACE_PERMISSION } from 'src/enums/workspace';
describe('WorkspaceGuard', () => {
let guard: WorkspaceGuard;
let reflector: Reflector;
let workspaceMemberService: WorkspaceMemberService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceGuard,
{
provide: Reflector,
useValue: {
get: jest.fn(),
},
},
{
provide: WorkspaceMemberService,
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
guard = module.get<WorkspaceGuard>(WorkspaceGuard);
reflector = module.get<Reflector>(Reflector);
workspaceMemberService = module.get<WorkspaceMemberService>(
WorkspaceMemberService,
);
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
it('should allow access if no roles are defined', async () => {
const context = createMockExecutionContext();
jest.spyOn(reflector, 'get').mockReturnValue(null);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw NoPermissionException if workspaceId is missing and optional is false', async () => {
const context = createMockExecutionContext();
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([WORKSPACE_PERMISSION.READ_WORKSPACE]);
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId');
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should allow access if workspaceId is missing and optional is true', async () => {
const context = createMockExecutionContext();
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([WORKSPACE_PERMISSION.WRITE_WORKSPACE]);
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce({ key: 'params.workspaceId', optional: true });
jest
.spyOn(workspaceMemberService, 'findOne')
.mockResolvedValue({ role: 'admin' } as WorkspaceMember);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
it('should throw NoPermissionException if user is not a member of the workspace', async () => {
const context = createMockExecutionContext();
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([WORKSPACE_PERMISSION.WRITE_WORKSPACE]);
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId');
jest.spyOn(workspaceMemberService, 'findOne').mockResolvedValue(null);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should throw NoPermissionException if user role is not allowed', async () => {
const context = createMockExecutionContext();
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([WORKSPACE_PERMISSION.READ_MEMBER]);
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId');
jest
.spyOn(workspaceMemberService, 'findOne')
.mockResolvedValue({ role: 'member' } as WorkspaceMember);
await expect(guard.canActivate(context)).rejects.toThrow(
NoPermissionException,
);
});
it('should allow access if user role is allowed', async () => {
const context = createMockExecutionContext();
jest
.spyOn(reflector, 'get')
.mockReturnValueOnce([WORKSPACE_PERMISSION.READ_MEMBER]);
jest.spyOn(reflector, 'get').mockReturnValueOnce('params.workspaceId');
jest
.spyOn(workspaceMemberService, 'findOne')
.mockResolvedValue({ role: 'admin' } as WorkspaceMember);
const result = await guard.canActivate(context);
expect(result).toBe(true);
});
function createMockExecutionContext(): ExecutionContext {
return {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({
user: { _id: 'testUserId' },
params: { workspaceId: 'workspaceId' },
}),
}),
getHandler: jest.fn(),
} as unknown as ExecutionContext;
}
});

View File

@ -0,0 +1,25 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { AuthenticationException } from '../exceptions/authException';
import { AuthService } from 'src/modules/auth/services/auth.service';
@Injectable()
export class Authentication implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
throw new AuthenticationException('请登录');
}
try {
const user = await this.authService.verifyToken(token);
request.user = user;
return true;
} catch (error) {
throw new AuthenticationException(error?.message || '用户凭证错误');
}
}
}

View File

@ -1,40 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { UserService } from '../modules/auth/services/user.service';
import { verify } from 'jsonwebtoken';
import { ConfigService } from '@nestjs/config';
import { AuthtificationException } from '../exceptions/authException';
@Injectable()
export class Authtication implements CanActivate {
constructor(
private readonly userService: UserService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) {
throw new AuthtificationException('请登录');
}
let decoded;
try {
decoded = verify(
token,
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
);
} catch (err) {
throw new AuthtificationException('用户凭证错误');
}
const user = await this.userService.getUserByUsername(decoded.username); // 从数据库中查找用户
if (!user) {
throw new AuthtificationException('用户不存在');
}
request.user = user; // 将用户信息存储在请求中
return true;
}
}

View File

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

@ -0,0 +1,87 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
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';
import { NoPermissionException } from 'src/exceptions/noPermissionException';
@Injectable()
export class SurveyGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly collaboratorService: CollaboratorService,
private readonly surveyMetaService: SurveyMetaService,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
const surveyIdKey = this.reflector.get<string>(
'surveyId',
context.getHandler(),
);
const surveyId = get(request, surveyIdKey);
if (!surveyId) {
return true;
}
const surveyMeta = await this.surveyMetaService.getSurveyById({ surveyId });
if (!surveyMeta) {
throw new SurveyNotFoundException('问卷不存在');
}
request.surveyMeta = surveyMeta;
// 兼容老的问卷没有ownerId
if (
surveyMeta.ownerId === user._id.toString() ||
surveyMeta.owner === user.username
) {
// 问卷的owner可以访问和操作问卷
return true;
}
if (surveyMeta.workspaceId) {
const memberInfo = await this.workspaceMemberService.findOne({
workspaceId: surveyMeta.workspaceId,
userId: user._id.toString(),
});
if (!memberInfo) {
throw new NoPermissionException('没有权限');
}
return true;
}
const permissions = this.reflector.get<string[]>(
'surveyPermission',
context.getHandler(),
);
if (!Array.isArray(permissions) || permissions.length === 0) {
throw new NoPermissionException('没有权限');
}
const info = await this.collaboratorService.getCollaborator({
surveyId,
userId: user._id.toString(),
});
if (!info) {
throw new NoPermissionException('没有权限');
}
request.collaborator = info;
if (
permissions.some((permission) => info.permissions.includes(permission))
) {
return true;
}
throw new NoPermissionException('没有权限');
}
}

View File

@ -0,0 +1,72 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { get } from 'lodash';
import { NoPermissionException } from '../exceptions/noPermissionException';
import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { ROLE_PERMISSION as WORKSPACE_ROLE_PERMISSION } from 'src/enums/workspace';
@Injectable()
export class WorkspaceGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly workspaceMemberService: WorkspaceMemberService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const allowPermissions = this.reflector.get<string[]>(
'workspacePermissions',
context.getHandler(),
);
if (!allowPermissions) {
return true; // 没有定义权限,可以访问
}
const request = context.switchToHttp().getRequest();
const user = request.user;
const workspaceIdInfo = this.reflector.get(
'workspaceId',
context.getHandler(),
);
let workspaceIdKey, optional;
if (typeof workspaceIdInfo === 'string') {
workspaceIdKey = workspaceIdInfo;
optional = false;
} else {
workspaceIdKey = workspaceIdInfo?.key;
optional = workspaceIdInfo?.optional || false;
}
const workspaceId = get(request, workspaceIdKey);
if (!workspaceId && optional === false) {
throw new NoPermissionException('没有空间权限');
}
if (workspaceId) {
const membersInfo = await this.workspaceMemberService.findOne({
workspaceId,
userId: user._id.toString(),
});
if (!membersInfo) {
throw new NoPermissionException('没有空间权限');
}
const userPermissions = WORKSPACE_ROLE_PERMISSION[membersInfo.role] || [];
if (
allowPermissions.some((permission) =>
userPermissions.includes(permission),
)
) {
return true;
}
throw new NoPermissionException('没有权限');
}
return true;
}
}

View File

@ -39,35 +39,34 @@ export interface DataItem {
showType: boolean;
showSpliter: boolean;
type: string;
valid: string;
valid?: string;
field: string;
title: string;
placeholder: string;
randomSort: boolean;
randomSort?: boolean;
checked: boolean;
minNum: string;
maxNum: string;
star: number;
nps: NPS;
nps?: NPS;
placeholderDesc: string;
addressType: number;
isAuto: boolean;
urlKey: string;
textRange: TextRange;
textRange?: TextRange;
options?: Option[];
importKey?: string;
importData?: string;
cOption?: string;
cOptions?: string[];
exclude?: boolean;
rangeConfig?: any;
starStyle?: string;
innerType?: string;
}
export interface Option {
text: string;
imageUrl: string;
others: boolean;
mustOthers: boolean;
othersKey: string;
mustOthers?: boolean;
othersKey?: string;
placeholderDesc: string;
hash: string;
}
@ -95,18 +94,63 @@ export interface SubmitConf {
msgContent: MsgContent;
}
// 白名单类型
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 {
logoImage: string;
logoImageWidth: string;
}
export interface SurveySchemaInterface {
@ -115,4 +159,5 @@ export interface SurveySchemaInterface {
submitConf: SubmitConf;
baseConf: BaseConf;
skinConf: SkinConf;
bottomConf: BottomConf;
}

View File

@ -1,14 +1,18 @@
import * as log4js from 'log4js';
import moment from 'moment';
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 traceId: string = '';
private inited = false;
private static inited = false;
init(config: { filename: string }) {
if (this.inited) {
constructor(@Inject(CONTEXT) private readonly ctx: RequestContext) {}
static init(config: { filename: string }) {
if (Logger.inited) {
return;
}
log4js.configure({
@ -29,29 +33,26 @@ export class Logger {
default: { appenders: ['app'], level: 'trace' },
},
});
}
setTraceId(traceId: string) {
this.traceId = traceId;
Logger.inited = true;
}
_log(message, options: { dltag?: string; level: string }) {
const datetime = moment().format('YYYY-MM-DD HH:mm:ss.SSS');
const level = options.level;
const dltag = options.dltag ? `${options.dltag}||` : '';
const traceId = this.traceId ? `traceid=${this.traceId}||` : '';
const level = options?.level;
const dltag = options?.dltag ? `${options.dltag}||` : '';
const traceIdStr = this.ctx?.['traceId']
? `traceid=${this.ctx?.['traceId']}||`
: '';
return log4jsLogger[level](
`[${datetime}][${level.toUpperCase()}]${dltag}${traceId}${message}`,
`[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`,
);
}
info(message, options = { dltag: '' }) {
info(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'info' });
}
error(message, options = { dltag: '' }) {
error(message, options?: { dltag?: string }) {
return this._log(message, { ...options, level: 'error' });
}
}
export default new Logger();

View File

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

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,9 +1,27 @@
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;
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('XIAOJU SURVEY')
.setDescription('')
.setVersion('1.0')
.addTag('auth')
.addTag('survey')
.addTag('surveyResponse')
.addTag('messagePushingTasks')
.addTag('ui')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swagger', app, document);
await app.listen(PORT);
console.log(`server is running at: http://127.0.0.1:${PORT}`);
}

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路径
@ -13,7 +12,7 @@ export class LogRequestMiddleware implements NestMiddleware {
const userAgent = req.get('user-agent') || '';
const startTime = Date.now();
const traceId = genTraceId({ ip });
this.logger.setTraceId(traceId);
req['traceId'] = traceId;
const query = JSON.stringify(req.query);
const body = JSON.stringify(req.body);
this.logger.info(

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

@ -0,0 +1,42 @@
import { SurveyResponse } from '../surveyResponse.entity';
import pluginManager from 'src/securityPlugin/pluginManager';
import { ResponseSecurityPlugin } from 'src/securityPlugin/responseSecurityPlugin';
import { cloneDeep } from 'lodash';
const mockOriginData = {
data405: '浙江省杭州市西湖区xxx',
data450: '450111000000000000',
data458: '15000000000',
data515: '115019',
data770: '123456@qq.com',
};
describe('SurveyResponse', () => {
beforeEach(() => {
pluginManager.registerPlugin(
new ResponseSecurityPlugin('dataAesEncryptSecretKey'),
);
});
it('should encrypt and decrypt success', async () => {
const surveyResponse = new SurveyResponse();
surveyResponse.data = cloneDeep(mockOriginData);
await surveyResponse.onDataInsert();
expect(surveyResponse.data.data405).not.toBe(mockOriginData.data405);
expect(surveyResponse.data.data450).not.toBe(mockOriginData.data450);
expect(surveyResponse.data.data458).not.toBe(mockOriginData.data458);
expect(surveyResponse.data.data770).not.toBe(mockOriginData.data770);
expect(surveyResponse.secretKeys).toEqual([
'data405',
'data450',
'data458',
'data770',
]);
surveyResponse.onDataLoaded();
expect(surveyResponse.data.data405).toBe(mockOriginData.data405);
expect(surveyResponse.data.data450).toBe(mockOriginData.data450);
expect(surveyResponse.data.data458).toBe(mockOriginData.data458);
expect(surveyResponse.data.data770).toBe(mockOriginData.data770);
});
});

View File

@ -0,0 +1,13 @@
import { ObjectIdColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
export class BaseEntity {
@ObjectIdColumn()
_id: ObjectId;
@CreateDateColumn({ type: 'timestamp', precision: 3 })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp', precision: 3 })
updatedAt: Date;
}

View File

@ -1,58 +1,15 @@
import {
Entity,
Column,
Index,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { BaseEntity } from './base.entity';
@Entity({ name: 'captcha' })
export class Captcha {
export class Captcha extends BaseEntity {
@Index({
expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@Column()
text: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -1,36 +1,16 @@
import {
Entity,
Column,
Index,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { ENCRYPT_TYPE } from '../enums/encrypt';
import { BaseEntity } from './base.entity';
@Entity({ name: 'clientEncrypt' })
export class ClientEncrypt {
export class ClientEncrypt extends BaseEntity {
@Index({
expireAfterSeconds:
new Date(Date.now() + 2 * 60 * 60 * 1000).getTime() / 1000,
expireAfterSeconds: 3600,
})
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column('jsonb')
data: {
secretKey?: string; // aes加密的密钥
@ -40,27 +20,4 @@ export class ClientEncrypt {
@Column()
type: ENCRYPT_TYPE;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -0,0 +1,26 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'collaborator' })
export class Collaborator extends BaseEntity {
@Column()
surveyId: string;
@Column()
userId: string;
@Column('jsonb')
permissions: Array<string>;
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -1,36 +1,8 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'counter' })
export class Counter {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
export class Counter extends BaseEntity {
@Column()
key: string;
@ -42,21 +14,4 @@ export class Counter {
@Column('jsonb')
data: Record<string, any>;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -0,0 +1,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

@ -0,0 +1,17 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'messagePushingLog' })
export class MessagePushingLog extends BaseEntity {
@Column()
taskId: string;
@Column('jsonb')
request: Record<string, any>;
@Column('jsonb')
response: Record<string, any>;
@Column()
status: number; // http状态码
}

View File

@ -0,0 +1,42 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
import {
MESSAGE_PUSHING_TYPE,
MESSAGE_PUSHING_HOOK,
} from 'src/enums/messagePushing';
@Entity({ name: 'messagePushingTask' })
export class MessagePushingTask extends BaseEntity {
@Column()
name: string;
@Column()
type: MESSAGE_PUSHING_TYPE;
@Column()
pushAddress: string; // 如果是http推送则是http的链接
@Column()
triggerHook: MESSAGE_PUSHING_HOOK;
@Column('jsonb')
surveys: Array<string>;
@Column()
creatorId: string;
@Column()
ownerId: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -1,37 +1,10 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
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 {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
export class ResponseSchema extends BaseEntity {
@Column()
title: string;
@ -44,20 +17,18 @@ export class ResponseSchema {
@Column()
pageId: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
@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

@ -1,57 +1,11 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { Entity, Column } from 'typeorm';
import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyConf' })
export class SurveyConf {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column({ type: 'bigint' })
createDate: number;
@Column({ type: 'bigint' })
updateDate: number;
export class SurveyConf extends BaseEntity {
@Column('jsonb')
code: SurveySchemaInterface;
@Column()
pageId: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -1,37 +1,10 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { HISTORY_TYPE, RECORD_STATUS } from '../enums';
import { Entity, Column } from 'typeorm';
import { HISTORY_TYPE } from '../enums';
import { SurveySchemaInterface } from '../interfaces/survey';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveyHistory' })
export class SurveyHistory {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
export class SurveyHistory extends BaseEntity {
@Column()
pageId: string;
@ -47,20 +20,6 @@ export class SurveyHistory {
_id: string;
};
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
@Column('string')
sessionId: string;
}

View File

@ -1,36 +1,9 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
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 {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
export class SurveyMeta extends BaseEntity {
@Column()
title: string;
@ -46,15 +19,54 @@ export class SurveyMeta {
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
owner: string;
@Column()
ownerId: string;
@Column()
createMethod: string;
@Column()
createFrom: string;
@Column()
workspaceId: string;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
subStatus: {
status: RECORD_SUB_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS | RECORD_SUB_STATUS;
date: number;
}>;
@Column()
operator: string;
@Column()
operatorId: string;
@Column()
isDeleted: boolean;
@Column()
deletedAt: Date;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
@ -63,12 +75,9 @@ export class SurveyMeta {
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
if (!this.subStatus) {
const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now };
this.subStatus = subStatus;
}
}
}

View File

@ -1,20 +1,9 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
AfterLoad,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { Entity, Column, BeforeInsert, AfterLoad } from 'typeorm';
import pluginManager from '../securityPlugin/pluginManager';
import { BaseEntity } from './base.entity';
@Entity({ name: 'surveySubmit' })
export class SurveyResponse {
@ObjectIdColumn()
_id: ObjectId;
export class SurveyResponse extends BaseEntity {
@Column()
pageId: string;
@ -25,7 +14,7 @@ export class SurveyResponse {
data: Record<string, any>;
@Column()
difTime: number;
diffTime: number;
@Column()
clientTime: number;
@ -36,44 +25,13 @@ export class SurveyResponse {
@Column('jsonb')
optionTextAndId: Record<string, any>;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
pluginManager.triggerHook('beforeResponseDataCreate', this);
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
async onDataInsert() {
return await pluginManager.triggerHook('encryptResponseData', this);
}
@AfterLoad()
onDataLoaded() {
pluginManager.triggerHook('afterResponseDataReaded', this);
async onDataLoaded() {
return await pluginManager.triggerHook('decryptResponseData', this);
}
}

View File

@ -1,56 +1,10 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'user' })
export class User {
@ObjectIdColumn()
_id: ObjectId;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
export class User extends BaseEntity {
@Column()
username: string;
@Column()
password: string;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -1,56 +1,10 @@
import {
Entity,
Column,
ObjectIdColumn,
BeforeInsert,
BeforeUpdate,
} from 'typeorm';
import { ObjectId } from 'mongodb';
import { RECORD_STATUS } from '../enums';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'word' })
export class Word {
@ObjectIdColumn()
_id: ObjectId;
export class Word extends BaseEntity {
@Column()
text: string;
@Column()
type: string;
@Column()
curStatus: {
status: RECORD_STATUS;
date: number;
};
@Column()
statusList: Array<{
status: RECORD_STATUS;
date: number;
}>;
@Column()
createDate: number;
@Column()
updateDate: number;
@BeforeInsert()
initDefaultInfo() {
const now = Date.now();
if (!this.curStatus) {
const curStatus = { status: RECORD_STATUS.NEW, date: now };
this.curStatus = curStatus;
this.statusList = [curStatus];
}
this.createDate = now;
this.updateDate = now;
}
@BeforeUpdate()
onUpdate() {
this.updateDate = Date.now();
}
}

View File

@ -0,0 +1,35 @@
import { Entity, Column } from 'typeorm';
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

@ -0,0 +1,26 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';
@Entity({ name: 'workspaceMember' })
export class WorkspaceMember extends BaseEntity {
@Column()
userId: string;
@Column()
workspaceId: string;
@Column()
role: string;
@Column()
creator: string;
@Column()
creatorId: string;
@Column()
operator: string;
@Column()
operatorId: string;
}

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthController } from '../controllers/auth.controller';
import { UserService } from '../services/user.service';
import { CaptchaService } from '../services/captcha.service';
import { AuthService } from '../services/auth.service';
@ -10,6 +10,7 @@ import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { ObjectId } from 'mongodb';
import { User } from 'src/models/user.entity';
import { Captcha } from 'src/models/captcha.entity';
jest.mock('../services/captcha.service');
jest.mock('../services/auth.service');
@ -23,7 +24,7 @@ describe('AuthController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
// imports: [ConfigModule.forRoot()],
controllers: [AuthController],
providers: [UserService, CaptchaService, ConfigService, AuthService],
}).compile();
@ -81,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', () => {
@ -95,12 +112,21 @@ describe('AuthController', () => {
jest
.spyOn(captchaService, 'checkCaptchaIsCorrect')
.mockResolvedValue(true);
jest.spyOn(userService, 'getUser').mockResolvedValue(
Promise.resolve({
username: 'testUser',
_id: new ObjectId(),
} as User),
);
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(
Promise.resolve({
username: 'testUser',
_id: new ObjectId(),
} as User),
);
jest.spyOn(authService, 'generateToken').mockResolvedValue('testToken');
const result = await controller.login(mockUserInfo);
@ -142,11 +168,81 @@ describe('AuthController', () => {
jest
.spyOn(captchaService, 'checkCaptchaIsCorrect')
.mockResolvedValue(true);
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(null);
await expect(controller.login(mockUserInfo)).rejects.toThrow(
new HttpException(
'账号未注册,请进行注册',
EXCEPTION_CODE.USER_NOT_EXISTS,
),
);
});
it('should throw HttpException with USER_NOT_EXISTS code when user is not found', async () => {
const mockUserInfo = {
username: 'testUser',
password: 'testPassword',
captchaId: 'testCaptchaId',
captcha: 'testCaptcha',
};
jest
.spyOn(captchaService, 'checkCaptchaIsCorrect')
.mockResolvedValue(true);
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(
Promise.resolve({
username: 'testUser',
_id: new ObjectId(),
} as User),
);
jest.spyOn(userService, 'getUser').mockResolvedValue(null);
await expect(controller.login(mockUserInfo)).rejects.toThrow(
new HttpException('用户名或密码错误', EXCEPTION_CODE.USER_NOT_EXISTS),
new HttpException(
'用户名或密码错误',
EXCEPTION_CODE.USER_PASSWORD_WRONG,
),
);
});
});
describe('getCaptcha', () => {
it('should return captcha image and id', async () => {
const captcha = new Captcha();
const mockCaptchaId = new ObjectId();
captcha._id = mockCaptchaId;
jest.spyOn(captchaService, 'createCaptcha').mockResolvedValue(captcha);
const result = await controller.getCaptcha();
expect(result.code).toBe(200);
expect(result.data.id).toBe(mockCaptchaId.toString());
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

@ -0,0 +1,59 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { User } from 'src/models/user.entity';
import { AuthService } from '../services/auth.service';
import { UserService } from '../services/user.service';
jest.mock('jsonwebtoken', () => {
return {
sign: jest.fn(),
verify: jest.fn().mockReturnValue({}),
};
});
describe('AuthService', () => {
let service: AuthService;
let userService: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule],
providers: [
AuthService,
{ provide: UserService, useValue: { getUserByUsername: jest.fn() } },
],
}).compile();
service = module.get<AuthService>(AuthService);
userService = module.get<UserService>(UserService);
});
describe('generateToken', () => {
it('should generate token successfully', async () => {
const userData = { _id: 'mockUserId', username: 'mockUsername' };
const tokenConfig = {
secret: 'mockSecretKey',
expiresIn: '8h',
};
await service.generateToken(userData, tokenConfig);
expect(jwt.sign).toHaveBeenCalledWith(userData, tokenConfig.secret, {
expiresIn: tokenConfig.expiresIn,
});
});
});
describe('verifyToken', () => {
it('should verifyToken succeed', async () => {
const token = 'mock token';
jest
.spyOn(userService, 'getUserByUsername')
.mockResolvedValue({} as User);
await service.verifyToken(token);
expect(userService.getUserByUsername).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CaptchaService } from './captcha.service';
import { CaptchaService } from '../services/captcha.service';
import { MongoRepository } from 'typeorm';
import { Captcha } from 'src/models/captcha.entity';
import { ObjectId } from 'mongodb';
@ -82,8 +82,6 @@ describe('CaptchaService', () => {
expect(captchaRepository.delete).toHaveBeenCalledWith(mockCaptchaId);
});
// Add more test cases for different scenarios
});
describe('checkCaptchaIsCorrect', () => {

View File

@ -0,0 +1,82 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from '../controllers/user.controller';
import { UserService } from '../services/user.service';
import { GetUserListDto } from '../dto/getUserList.dto';
import { Authentication } from 'src/guards/authentication.guard';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { User } from 'src/models/user.entity';
describe('UserController', () => {
let userController: UserController;
let userService: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [
{
provide: UserService,
useValue: {
getUserListByUsername: jest.fn(),
},
},
],
})
.overrideGuard(Authentication)
.useValue({ canActivate: jest.fn(() => true) })
.compile();
userController = module.get<UserController>(UserController);
userService = module.get<UserService>(UserService);
});
describe('getUserList', () => {
it('should return a list of users', async () => {
const mockUserList = [
{ _id: '1', username: 'user1' },
{ _id: '2', username: 'user2' },
];
jest
.spyOn(userService, 'getUserListByUsername')
.mockResolvedValue(mockUserList as unknown as User[]);
const queryInfo: GetUserListDto = {
username: 'testuser',
pageIndex: 1,
pageSize: 10,
};
GetUserListDto.validate = jest
.fn()
.mockReturnValue({ value: queryInfo, error: null });
const result = await userController.getUserList(queryInfo);
expect(result).toEqual({
code: 200,
data: mockUserList.map((item) => ({
userId: item._id,
username: item.username,
})),
});
});
it('should throw an HttpException if validation fails', async () => {
const queryInfo: GetUserListDto = {
username: 'testuser',
pageIndex: 1,
pageSize: 10,
};
const validationError = new Error('Validation failed');
GetUserListDto.validate = jest
.fn()
.mockReturnValue({ value: null, error: validationError });
await expect(userController.getUserList(queryInfo)).rejects.toThrow(
new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR),
);
});
});
});

View File

@ -0,0 +1,265 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
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 { ObjectId } from 'mongodb';
describe('UserService', () => {
let service: UserService;
let userRepository: MongoRepository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
userRepository = module.get<MongoRepository<User>>(
getRepositoryToken(User),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create a user', async () => {
const userInfo = {
username: 'testUser',
password: 'testPassword',
} as User;
const createSpy = jest
.spyOn(userRepository, 'create')
.mockImplementation(() => userInfo);
const saveSpy = jest
.spyOn(userRepository, 'save')
.mockResolvedValue(userInfo);
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.createUser(userInfo);
expect(findOneSpy).toHaveBeenCalledWith({
where: { username: userInfo.username },
});
expect(createSpy).toHaveBeenCalledWith({
username: userInfo.username,
password: expect.any(String),
});
expect(saveSpy).toHaveBeenCalled();
expect(user).toEqual(userInfo);
});
it('should throw when trying to create an existing user', async () => {
const userInfo = {
username: 'existingUser',
password: 'existingPassword',
} as User;
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(userInfo);
await expect(service.createUser(userInfo)).rejects.toThrow(HttpException);
expect(findOneSpy).toHaveBeenCalledWith({
where: { username: userInfo.username },
});
});
it('should return a user by credentials', async () => {
const userInfo = {
username: 'existingUser',
password: 'existingPassword',
};
const hashedPassword = hash256(userInfo.password);
jest.spyOn(userRepository, 'findOne').mockImplementation(() => {
return Promise.resolve({
username: userInfo.username,
password: hashedPassword,
} as User);
});
const user = await service.getUser(userInfo);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
username: userInfo.username,
password: hashedPassword,
},
});
expect(user).toEqual({ ...userInfo, password: hashedPassword });
});
it('should return null when user is not found by credentials', async () => {
const userInfo = {
username: 'nonExistingUser',
password: 'nonExistingPassword',
};
const hashedPassword = hash256(userInfo.password);
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUser(userInfo);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
username: userInfo.username,
password: hashedPassword,
},
});
expect(user).toBe(null);
});
it('should return a user by username', async () => {
const username = 'existingUser';
const userInfo = {
username: username,
password: 'existingPassword',
curStatus: { status: 'ACTIVE' },
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
const user = await service.getUserByUsername(username);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
username: username,
},
});
expect(user).toEqual(userInfo);
});
it('should return null when user is not found by username', async () => {
const username = 'nonExistingUser';
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUserByUsername(username);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
username: username,
},
});
expect(user).toBe(null);
});
it('should return a user by id', async () => {
const id = '60c72b2f9b1e8a5f4b123456';
const userInfo = {
_id: new ObjectId(id),
username: 'testUser',
curStatus: { status: 'ACTIVE' },
} as unknown as User;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(userInfo);
const user = await service.getUserById(id);
expect(userRepository.findOne).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
},
});
expect(user).toEqual(userInfo);
});
it('should return null when user is not found by id', async () => {
const id = '60c72b2f9b1e8a5f4b123456';
const findOneSpy = jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue(null);
const user = await service.getUserById(id);
expect(findOneSpy).toHaveBeenCalledWith({
where: {
_id: new ObjectId(id),
},
});
expect(user).toBe(null);
});
it('should return a list of users by username', async () => {
const username = 'test';
const userList = [
{ _id: new ObjectId(), username: 'testUser1', createdAt: new Date() },
{ _id: new ObjectId(), username: 'testUser2', createdAt: new Date() },
];
jest
.spyOn(userRepository, 'find')
.mockResolvedValue(userList as unknown as User[]);
const result = await service.getUserListByUsername({
username,
skip: 0,
take: 10,
});
expect(userRepository.find).toHaveBeenCalledWith({
where: {
username: new RegExp(username),
},
skip: 0,
take: 10,
select: ['_id', 'username', 'createdAt'],
});
expect(result).toEqual(userList);
});
it('should return a list of users by ids', async () => {
const idList = ['60c72b2f9b1e8a5f4b123456', '60c72b2f9b1e8a5f4b123457'];
const userList = [
{
_id: new ObjectId(idList[0]),
username: 'testUser1',
createdAt: new Date(),
},
{
_id: new ObjectId(idList[1]),
username: 'testUser2',
createdAt: new Date(),
},
];
jest
.spyOn(userRepository, 'find')
.mockResolvedValue(userList as unknown as User[]);
const result = await service.getUserListByIds({ idList });
expect(userRepository.find).toHaveBeenCalledWith({
where: {
_id: {
$in: idList.map((id) => new ObjectId(id)),
},
},
select: ['_id', 'username', 'createdAt'],
});
expect(result).toEqual(userList);
});
});

View File

@ -4,6 +4,7 @@ import { AuthService } from './services/auth.service';
import { CaptchaService } from './services/captcha.service';
import { AuthController } from './controllers/auth.controller';
import { UserController } from './controllers/user.controller';
import { User } from 'src/models/user.entity';
import { Captcha } from 'src/models/captcha.entity';
@ -13,8 +14,8 @@ import { ConfigModule } from '@nestjs/config';
@Module({
imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule],
controllers: [AuthController],
controllers: [AuthController, UserController],
providers: [UserService, AuthService, CaptchaService],
exports: [UserService],
exports: [UserService, AuthService],
})
export class AuthModule {}

View File

@ -1,14 +1,24 @@
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { UserService } from '../services/user.service';
import { CaptchaService } from '../services/captcha.service'; // 假设你的验证码服务在这里
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';
import { AuthService } from '../services/auth.service';
import { HttpException } from 'src/exceptions/httpException';
import { create } from 'svg-captcha';
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 {
constructor(
@ -29,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,
@ -86,6 +114,16 @@ export class AuthController {
throw new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT);
}
const username = await this.userService.getUserByUsername(
userInfo.username,
);
if (!username) {
throw new HttpException(
'账号未注册,请进行注册',
EXCEPTION_CODE.USER_NOT_EXISTS,
);
}
const user = await this.userService.getUser({
username: userInfo.username,
password: userInfo.password,
@ -93,7 +131,7 @@ export class AuthController {
if (user === null) {
throw new HttpException(
'用户名或密码错误',
EXCEPTION_CODE.USER_NOT_EXISTS,
EXCEPTION_CODE.USER_PASSWORD_WRONG,
);
}
let token;
@ -153,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

@ -0,0 +1,65 @@
import {
Controller,
Get,
Query,
HttpCode,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Authentication } from 'src/guards/authentication.guard';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { HttpException } from 'src/exceptions/httpException';
import { UserService } from '../services/user.service';
import { GetUserListDto } from '../dto/getUserList.dto';
@ApiTags('user')
@ApiBearerAuth()
@Controller('/api/user')
export class UserController {
constructor(private readonly userService: UserService) {}
@UseGuards(Authentication)
@Get('/getUserList')
@HttpCode(200)
async getUserList(
@Query()
queryInfo: GetUserListDto,
) {
const { value, error } = GetUserListDto.validate(queryInfo);
if (error) {
throw new HttpException('参数有误', EXCEPTION_CODE.PARAMETER_ERROR);
}
const userList = await this.userService.getUserListByUsername({
username: value.username,
skip: (value.pageIndex - 1) * value.pageSize,
take: value.pageSize,
});
return {
code: 200,
data: userList.map((item) => {
return {
userId: item._id.toString(),
username: item.username,
};
}),
};
}
@UseGuards(Authentication)
@Get('/getUserInfo')
async getUserInfo(@Request() req) {
return {
code: 200,
data: {
userId: req.user._id.toString(),
username: req.user.username,
},
};
}
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import Joi from 'joi';
export class GetUserListDto {
@ApiProperty({ description: '用户名', required: true })
username: string;
@ApiProperty({ description: '页码', required: false, default: 1 })
pageIndex?: number;
@ApiProperty({ description: '每页查询数', required: false, default: 10 })
pageSize: number;
static validate(data) {
return Joi.object({
username: Joi.string().required(),
pageIndex: Joi.number().allow(null).default(1),
pageSize: Joi.number().allow(null).default(10),
}).validate(data);
}
}

View File

@ -1,33 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { sign } from 'jsonwebtoken';
jest.mock('jsonwebtoken');
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
}).compile();
service = module.get<AuthService>(AuthService);
});
describe('generateToken', () => {
it('should generate token successfully', async () => {
const userData = { _id: 'mockUserId', username: 'mockUsername' };
const tokenConfig = {
secret: 'mockSecretKey',
expiresIn: '8h',
};
await service.generateToken(userData, tokenConfig);
expect(sign).toHaveBeenCalledWith(userData, tokenConfig.secret, {
expiresIn: tokenConfig.expiresIn,
});
});
});
});

View File

@ -1,9 +1,15 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { sign } from 'jsonwebtoken';
import { sign, verify } from 'jsonwebtoken';
import { UserService } from './user.service';
@Injectable()
export class AuthService {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService,
) {}
async generateToken(
{ _id, username }: { _id: string; username: string },
{ secret, expiresIn }: { secret: string; expiresIn: string },
@ -12,4 +18,21 @@ export class AuthService {
expiresIn,
});
}
async verifyToken(token: string) {
let decoded;
try {
decoded = verify(
token,
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
);
} catch (err) {
throw new Error('用户凭证错误');
}
const user = await this.userService.getUserByUsername(decoded.username);
if (!user) {
throw new Error('用户不存在');
}
return user;
}
}

View File

@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { User } from 'src/models/user.entity';
import { createHash } from 'crypto';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { hash256 } from 'src/utils/hash256';
import { ObjectId } from 'mongodb';
@Injectable()
export class UserService {
@ -13,10 +14,6 @@ export class UserService {
private readonly userRepository: MongoRepository<User>,
) {}
private hash256(text) {
return createHash('sha256').update(text).digest('hex');
}
async createUser(userInfo: {
username: string;
password: string;
@ -31,7 +28,7 @@ export class UserService {
const newUser = this.userRepository.create({
username: userInfo.username,
password: this.hash256(userInfo.password),
password: hash256(userInfo.password),
});
return this.userRepository.save(newUser);
@ -44,7 +41,7 @@ export class UserService {
const user = await this.userRepository.findOne({
where: {
username: userInfo.username,
password: this.hash256(userInfo.password), // Please handle password hashing here
password: hash256(userInfo.password), // Please handle password hashing here
},
});
@ -60,4 +57,38 @@ export class UserService {
return user;
}
async getUserById(id: string) {
const user = await this.userRepository.findOne({
where: {
_id: new ObjectId(id),
},
});
return user;
}
async getUserListByUsername({ username, skip, take }) {
const list = await this.userRepository.find({
where: {
username: new RegExp(username),
},
skip,
take,
select: ['_id', 'username', 'createdAt'],
});
return list;
}
async getUserListByIds({ idList }) {
const list = await this.userRepository.find({
where: {
_id: {
$in: idList.map((item) => new ObjectId(item)),
},
},
select: ['_id', 'username', 'createdAt'],
});
return list;
}
}

View File

@ -0,0 +1,55 @@
import { AliOssHandler } from '../services/uploadHandlers/alioss.handler';
describe('AliOssHandler', () => {
describe('upload', () => {
it('should upload a file and return the key', async () => {
const file = {
originalname: 'mockFileName.txt',
buffer: Buffer.from('mockFileContent'),
} as Express.Multer.File;
const mockPutResult = { name: 'mockFileName.txt' };
const mockClient: any = {
put: jest.fn().mockResolvedValue(mockPutResult),
};
const handler = new AliOssHandler({
client: mockClient,
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = await handler.upload(file);
expect(result).toEqual({
key: expect.stringMatching(/\w+\.txt/),
});
});
});
describe('getUrl', () => {
it('should return the URL for the given key', () => {
const key = 'mockFilePath/mockFileName.txt';
const bucket = 'xiaojusurvey';
const accessKey = 'mockAccessKey';
const region = 'mockRegion';
const handler = new AliOssHandler({
accessKey,
secretKey: 'mockSecretKey',
bucket,
region,
endPoint: 'mockEndPoint',
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = handler.getUrl(key);
const keyReg = new RegExp(key);
const signatureReg = new RegExp('Signature=');
const expiredReg = new RegExp('Expires=');
expect(keyReg.test(result)).toBe(true);
expect(signatureReg.test(result)).toBe(true);
expect(expiredReg.test(result)).toBe(true);
});
});
});

View File

@ -0,0 +1,150 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FileController } from '../controllers/file.controller';
import { FileService } from '../services/file.service';
import { uploadConfig, channels } from '../config/index';
import { AuthenticationException } from 'src/exceptions/authException';
import { HttpException } from 'src/exceptions/httpException';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { User } from 'src/models/user.entity';
describe('FileController', () => {
let controller: FileController;
let fileService: FileService;
let authService: AuthService;
// let configService: ConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forFeature(() => {
return {
...channels,
...uploadConfig,
};
}),
],
controllers: [FileController],
providers: [
FileService,
{
provide: AuthService,
useValue: {
verifyToken: jest.fn(),
},
},
ConfigService,
],
}).compile();
controller = module.get<FileController>(FileController);
fileService = module.get<FileService>(FileService);
authService = module.get<AuthService>(AuthService);
// configService = module.get<ConfigService>(ConfigService);
});
describe('upload', () => {
it('should upload a file', async () => {
const file: Express.Multer.File = {
fieldname: '',
originalname: '',
encoding: '',
mimetype: '',
size: 0,
stream: null,
destination: '',
filename: '',
path: '',
buffer: null,
};
const req = { headers: { authorization: 'Bearer mockToken' } };
const reqBody = { channel: 'upload' };
jest.spyOn(authService, 'verifyToken').mockResolvedValueOnce({} as User);
const mockUploadResult = { key: 'mockKey', url: 'mockUrl' };
jest.spyOn(fileService, 'upload').mockResolvedValueOnce(mockUploadResult);
const result = await controller.upload(file, req, reqBody);
expect(result).toEqual({
code: 200,
data: {
url: mockUploadResult.url,
key: mockUploadResult.key,
},
});
});
it('should upload failed without token', async () => {
const file: Express.Multer.File = {
fieldname: '',
originalname: '',
encoding: '',
mimetype: '',
size: 0,
stream: null,
destination: '',
filename: '',
path: '',
buffer: null,
};
const req = { headers: { authorization: '' } };
const reqBody = { channel: 'upload' };
await expect(controller.upload(file, req, reqBody)).rejects.toThrow(
AuthenticationException,
);
});
it('should upload failed', async () => {
const file: Express.Multer.File = {
fieldname: '',
originalname: '',
encoding: '',
mimetype: '',
size: 0,
stream: null,
destination: '',
filename: '',
path: '',
buffer: null,
};
const req = { headers: { authorization: 'Bearer mockToken' } };
const reqBody = { channel: 'mockChannel' };
await expect(controller.upload(file, req, reqBody)).rejects.toThrow(
HttpException,
);
});
});
describe('generateGetUrl', () => {
it('should generate a URL for the given channel and key', async () => {
const reqBody = { channel: 'upload', key: 'mockKey' };
const mockUrl = 'mockGeneratedUrl';
jest.spyOn(fileService, 'getUrl').mockReturnValueOnce(mockUrl);
const result = await controller.generateGetUrl(reqBody);
expect(result).toEqual({
code: 200,
data: {
key: reqBody.key,
url: mockUrl,
},
});
});
it('should generate a URL failed', async () => {
const reqBody = { channel: 'mockChannel', key: 'mockKey' };
await expect(controller.generateGetUrl(reqBody)).rejects.toThrow(
HttpException,
);
});
});
});

View File

@ -0,0 +1,65 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FileService } from '../services/file.service';
import { LocalHandler } from '../services/uploadHandlers/local.handler';
import { uploadConfig, channels } from '../config/index';
describe('FileService', () => {
let fileService: FileService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forFeature(() => {
return {
...channels,
...uploadConfig,
};
}),
],
providers: [FileService, ConfigService],
}).compile();
fileService = module.get<FileService>(FileService);
});
describe('upload', () => {
it('should upload a file and return key and URL', async () => {
const configKey = 'mockConfigKey';
const file = {} as Express.Multer.File;
const pathPrefix = 'mockPathPrefix';
const mockKey = 'mockKey';
jest
.spyOn(LocalHandler.prototype, 'upload')
.mockResolvedValueOnce({ key: mockKey });
const mockUrl = 'mockUrl';
jest
.spyOn(LocalHandler.prototype, 'getUrl')
.mockResolvedValueOnce(mockUrl as never);
const result = await fileService.upload({ configKey, file, pathPrefix });
expect(result).toEqual({
key: mockKey,
url: mockUrl,
});
});
});
describe('getUrl', () => {
it('should return URL for the given configKey and key', async () => {
const configKey = 'mockConfigKey';
const key = 'mockKey';
const mockUrl = 'mockUrl';
jest.spyOn(LocalHandler.prototype, 'getUrl').mockReturnValueOnce(mockUrl);
const result = fileService.getUrl({ configKey, key });
expect(result).toEqual(mockUrl);
});
});
});

View File

@ -0,0 +1,32 @@
import { join } from 'path';
import { LocalHandler } from '../services/uploadHandlers/local.handler';
describe('LocalHandler', () => {
describe('upload', () => {
it('should upload a file and return the key', async () => {
const file = {
originalname: `mockFileName.txt`,
buffer: Buffer.from('mockFileContent'),
} as Express.Multer.File;
const physicalRootPath = join(__dirname, 'tmp');
const handler = new LocalHandler({ physicalRootPath });
const result = await handler.upload(file);
expect(result).toEqual({
key: expect.stringMatching(/\w+\.txt/),
});
});
});
describe('getUrl', () => {
it('should return the URL for the given key', () => {
const key = 'mockFilePath/mockFileName.txt';
const physicalRootPath = join(__dirname, 'tmp');
const handler = new LocalHandler({ physicalRootPath });
const result = handler.getUrl(key);
expect(result).toBe(`/${key}`);
});
});
});

View File

@ -0,0 +1,75 @@
import { Client } from 'minio';
import { MinIOHandler } from '../services/uploadHandlers/minio.handler';
import { HttpException } from 'src/exceptions/httpException';
describe('MinIOHandler', () => {
describe('upload', () => {
it('should upload a file and return the key', async () => {
const file = {
originalname: 'mockFileName.txt',
buffer: Buffer.from('mockFileContent'),
} as Express.Multer.File;
const mockPutObjectFn = jest.fn().mockResolvedValueOnce({});
const mockClient = {
putObject: mockPutObjectFn,
};
const handler = new MinIOHandler({
client: mockClient as unknown as Client,
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = await handler.upload(file);
expect(result).toEqual({
key: expect.stringMatching('.txt'),
});
expect(mockPutObjectFn).toHaveBeenCalledTimes(1);
});
it('should throw an HttpException if upload fails', async () => {
const file = {
originalname: 'mockFileName.txt',
buffer: Buffer.from('mockFileContent'),
} as Express.Multer.File;
const mockPutObjectFn = jest
.fn()
.mockRejectedValueOnce(new Error('Upload failed'));
const mockClient = {
putObject: mockPutObjectFn,
};
const handler = new MinIOHandler({
client: mockClient as unknown as Client,
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
await expect(handler.upload(file)).rejects.toThrow(HttpException);
});
});
describe('getUrl', () => {
it('should return the URL for the given key', async () => {
const key = 'mockFilePath/mockFileName.txt';
const handler = new MinIOHandler({
accessKey: 'mockAccessKey',
secretKey: 'mockSecretKey',
bucket: 'xiaojusurvey',
region: 'mockRegion',
endPoint: 'mockEndPoint',
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = await handler.getUrl(key);
const keyReg = new RegExp(key);
const signatureReg = new RegExp('X-Amz-Signature=');
expect(keyReg.test(result)).toBe(true);
expect(signatureReg.test(result)).toBe(true);
});
});
});

View File

@ -0,0 +1,102 @@
// In your test file
import { QiniuHandler } from '../services/uploadHandlers/qiniu.handler';
import qiniu from 'qiniu';
jest.mock('qiniu', () => ({
auth: {
digest: {
Mac: jest.fn(),
},
},
conf: {
Config: jest.fn(),
Zone: jest.fn(),
},
form_up: {
FormUploader: jest.fn().mockImplementation(() => {
return {
put: jest
.fn()
.mockImplementation(
(uploadToken, key, buffer, putExtra, callback) => {
callback && callback(null, null, { statusCode: 200 });
},
),
};
}),
PutExtra: jest.fn(),
},
rs: {
PutPolicy: jest.fn().mockImplementation(() => {
return {
uploadToken: jest.fn(),
};
}),
BucketManager: jest.fn().mockImplementation(() => {
return {
privateDownloadUrl: jest.fn().mockReturnValue(''),
publicDownloadUrl: jest.fn().mockReturnValue(''),
};
}),
},
}));
describe('QiniuHandler', () => {
describe('upload', () => {
it('should upload a file and return the key', async () => {
const file = {
originalname: 'mockFileName.txt',
buffer: Buffer.from('mockFileContent'),
} as Express.Multer.File;
const mockMacInstance = {
sign: jest.fn(),
};
jest
.spyOn(qiniu.auth.digest, 'Mac')
.mockImplementation(() => mockMacInstance as any);
// Create a new instance of QiniuHandler
const handler = new QiniuHandler({
accessKey: 'mockAccessKey',
secretKey: 'mockSecretKey',
bucket: 'mockBucket',
endPoint: 'mockEndPoint',
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = await handler.upload(file);
expect(result).toEqual({
key: expect.stringMatching('.txt'),
});
expect(qiniu.auth.digest.Mac).toHaveBeenCalledWith(
'mockAccessKey',
'mockSecretKey',
);
expect(qiniu.form_up.FormUploader).toHaveBeenCalled();
});
});
describe('getUrl', () => {
it('should return the URL for the given key', async () => {
const key = 'mockFilePath/mockFileName.txt';
const handler = new QiniuHandler({
accessKey: 'mockAccessKey',
secretKey: 'mockSecretKey',
bucket: 'xiaojusurvey',
endPoint: 'mockEndPoint',
useSSL: true,
isPrivateRead: true,
expiryTime: '1h',
});
const result = await handler.getUrl(key);
expect(typeof result).toBe('string');
});
});
});

View File

@ -0,0 +1,62 @@
const SERVER_LOCAL_CONFIG = {
LOCAL_STATIC_RENDER_TYPE: 'server', // nginx
IS_PRIVATE_READ: false,
FILE_KEY_PREFIX: 'userUpload', // 存储路径
NEED_AUTH: true,
};
const QINIU_CONFIG = {
FILE_STORAGE_PROVIDER: 'qiniu',
IS_PRIVATE_READ: false,
FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀会根据此处配置校验body的参数
NEED_AUTH: true, // 是否需要登录
LINK_EXPIRY_TIME: '2h',
// minio、oss或者七牛云配置
ACCESS_KEY: '', // your_access_key
SECRET_KEY: '', // your_secret_key
BUCKET: '', // your_bucket
ENDPOINT: '', // endpoint
USE_SSL: false, // useSSL
};
const ALI_OSS_CONFIG = {
FILE_STORAGE_PROVIDER: 'ali-oss',
IS_PRIVATE_READ: false,
FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀会根据此处配置校验body的参数
NEED_AUTH: true, // 是否需要登录
LINK_EXPIRY_TIME: '2h',
ACCESS_KEY: '', // your_access_key
SECRET_KEY: '', // your_secret_key
BUCKET: '', // your_bucket
REGION: '',
ENDPOINT: '', // endpoint
USE_SSL: false, // useSSL
};
export const MINIO_CONFIG = {
FILE_STORAGE_PROVIDER: 'minio',
IS_PRIVATE_READ: false,
FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀会根据此处配置校验body的参数
NEED_AUTH: true, // 是否需要登录
LINK_EXPIRY_TIME: '2h',
ACCESS_KEY: '', // your_access_key
SECRET_KEY: '', // your_secret_key
BUCKET: '', // your_bucket
REGION: '',
ENDPOINT: '', // endpoint
USE_SSL: true, // useSSL
};
export const channels = {
upload: 'SERVER_LOCAL_CONFIG',
};
export const uploadConfig = {
SERVER_LOCAL_CONFIG,
QINIU_CONFIG,
ALI_OSS_CONFIG,
MINIO_CONFIG,
};

View File

@ -0,0 +1,93 @@
import {
Controller,
Post,
UploadedFile,
UseInterceptors,
HttpCode,
Request,
Body,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ConfigService } from '@nestjs/config';
import { ApiTags } from '@nestjs/swagger';
import { FileService } from '../services/file.service';
import { HttpException } from 'src/exceptions/httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
import { AuthService } from 'src/modules/auth/services/auth.service';
import { AuthenticationException } from 'src/exceptions/authException';
@ApiTags('file')
@Controller('/api/file')
export class FileController {
constructor(
private readonly fileService: FileService,
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
@Post('upload')
@HttpCode(200)
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile() file: Express.Multer.File,
@Request() req,
@Body() reqBody,
) {
const { channel } = reqBody;
if (!channel || !this.configService.get<string>(channel)) {
throw new HttpException(
`参数有误channel不正确:${reqBody.channel}`,
EXCEPTION_CODE.PARAMETER_ERROR,
);
}
const configKey = this.configService.get<string>(channel);
const needAuth = this.configService.get<boolean>(`${configKey}.NEED_AUTH`);
if (needAuth) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
throw new AuthenticationException('请登录');
}
await this.authService.verifyToken(token);
}
const fileKeyPrefix = this.configService.get<string>(
`${configKey}.FILE_KEY_PREFIX`,
);
const { key, url } = await this.fileService.upload({
configKey,
file,
pathPrefix: fileKeyPrefix,
});
return {
code: 200,
data: {
url,
key,
},
};
}
@Post('getUrl')
@HttpCode(200)
async generateGetUrl(@Body() reqBody) {
const { channel, key } = reqBody;
if (!channel || !key || !this.configService.get<string>(channel)) {
throw new HttpException(
'参数有误请检查channel、key',
EXCEPTION_CODE.PARAMETER_ERROR,
);
}
const configKey = this.configService.get<string>(channel);
const url = this.fileService.getUrl({ configKey, key });
return {
code: 200,
data: {
key,
url,
},
};
}
}

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module';
import { FileService } from './services/file.service';
import { FileController } from './controllers/file.controller';
import { uploadConfig, channels } from './config/index';
@Module({
imports: [
// 管理端和渲染端分开配置,因为管理端上传的内容一般需要公开,渲染端上传的内容一般需要限制访问,防止被当作图床
ConfigModule.forFeature(() => {
return {
...channels,
...uploadConfig,
};
}),
AuthModule,
],
controllers: [FileController],
providers: [FileService],
})
export class FileModule {}

View File

@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LocalHandler } from './uploadHandlers/local.handler';
// import { QiniuHandler } from './uploadHandlers/qiniu.handler';
// import { AliOssHandler } from './uploadHandlers/alioss.handler';
// import { MinIOHandler } from './uploadHandlers/minio.handler';
@Injectable()
export class FileService {
constructor(private readonly configService: ConfigService) {}
async upload({
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,
filename,
});
const url = await handler.getUrl(key);
return {
key,
url,
};
}
getUrl({ configKey, key }) {
const handler = this.getHandler(configKey);
return handler.getUrl(key);
}
private getHandler(configKey: string) {
const staticType = this.configService.get<string>(
`${configKey}.LOCAL_STATIC_RENDER_TYPE`,
);
let physicalRootPath;
if (staticType === 'nginx') {
physicalRootPath = this.configService.get<string>(
`${configKey}.NGINX_STATIC_PATH`,
);
}
if (!physicalRootPath) {
physicalRootPath = 'public';
}
return new LocalHandler({ physicalRootPath });
// qiniu
// return new QiniuHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// });
// ali-oss
// return new AliOssHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// region: this.configService.get<string>(`${configKey}.REGION`),
// });
// minio
// return new MinIOHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// region: this.configService.get<string>(`${configKey}.REGION`),
// });
}
}

View File

@ -0,0 +1,78 @@
import OSS from 'ali-oss';
import { generateUniqueFilename } from '../../utils/generateUniqueFilename';
import { join } from 'path';
import { parseExpiryTimeToSeconds } from '../../utils/parseExpiryTimeToSeconds';
import { FileUploadHandler } from './uploadHandler.interface';
export class AliOssHandler implements FileUploadHandler {
private client: OSS;
endPoint: string;
useSSL: boolean;
isPrivateRead: boolean;
expiryTime: string;
constructor({
client,
accessKey,
secretKey,
bucket,
region,
endPoint,
useSSL,
isPrivateRead,
expiryTime,
}: {
client?: OSS;
accessKey?: string;
secretKey?: string;
bucket?: string;
region?: string;
endPoint?: string;
useSSL?: boolean;
isPrivateRead?: boolean;
expiryTime?: string;
}) {
if (!client) {
client = new OSS({
region,
accessKeyId: accessKey,
accessKeySecret: secretKey,
bucket,
});
}
this.client = client;
this.endPoint = endPoint;
this.useSSL = useSSL;
this.isPrivateRead = isPrivateRead;
this.expiryTime = expiryTime;
}
async upload(
file: Express.Multer.File,
options?: {
pathPrefix?: string;
},
): Promise<{ key: string }> {
const { pathPrefix } = options || {};
const key = join(
pathPrefix || '',
await generateUniqueFilename(file.originalname),
);
await this.client.put(key, file.buffer);
return { key };
}
getUrl(key: string): string {
const expireTimeSeconds = parseExpiryTimeToSeconds(this.expiryTime);
if (this.isPrivateRead) {
const url = this.client.signatureUrl(key, {
expires: expireTimeSeconds,
method: 'GET',
});
return url;
} else {
return `${this.useSSL ? 'https' : 'http'}://${this.endPoint}/${key}`;
}
}
}

View File

@ -0,0 +1,51 @@
import { join, dirname, sep } from 'path';
import fse from 'fs-extra';
import { createWriteStream } from 'fs';
import { FileUploadHandler } from './uploadHandler.interface';
import { generateUniqueFilename } from '../../utils/generateUniqueFilename';
export class LocalHandler implements FileUploadHandler {
private physicalRootPath: string;
constructor({ physicalRootPath }: { physicalRootPath: string }) {
this.physicalRootPath = physicalRootPath;
}
async upload(
file: Express.Multer.File,
options?: { pathPrefix?: string; filename?: string },
): Promise<{ key: string }> {
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);
return new Promise((resolve, reject) => {
writeStream.on('finish', () =>
resolve({
key: filePath,
}),
);
writeStream.on('error', reject);
writeStream.write(file.buffer);
writeStream.end();
});
}
getUrl(key: string): string {
if (process.env.SERVER_ENV === 'local') {
const port = process.env.PORT || 3000;
return `http://localhost:${port}/${key}`;
}
return `/${key}`;
}
}

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