Compare commits
No commits in common. "feature/server-java" and "main" have entirely different histories.
feature/se
...
main
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: 反馈遇到的问题 / 发现的Bug
|
||||
title: ''
|
||||
labels: 'Bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### 分类
|
||||
<!-- 前端\后端\平台功能\环境 -->
|
||||
|
||||
### 系统
|
||||
|
||||
### 复现步骤
|
||||
<!-- 描述具体内容,越详细越好,有截图更好 -->
|
||||
|
||||
### 预期结果
|
||||
|
||||
### 实际结果
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
17
.github/ISSUE_TEMPLATE/pr_request.md
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
name: PR request
|
||||
about: 关于某个PR的详细描述
|
||||
title: '[类型]: xxx'
|
||||
labels: 'pr'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### 改动原因
|
||||
<!-- 清晰描述PR目的、原因 -->
|
||||
|
||||
### 改动内容
|
||||
<!-- 详细描述改了什么,必要的话配截图 -->
|
||||
|
||||
### 改动验证
|
||||
<!-- 如何验证的,必要的修改需要补充单测 -->
|
10
.github/ISSUE_TEMPLATE/😊-抛个问题---想法---建议.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/😊-抛个问题---想法---建议.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
name: "\U0001F60A 抛个问题 / 想法 / 建议"
|
||||
about: 提个项目相关的问题 / 想法 / 建议
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- 提出项目相关的任何问题、想法、建议或您想讨论的话题。或者只是想聊聊 -->
|
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
44
.github/workflows/codecov.yml
vendored
Normal 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 }}
|
37
.github/workflows/server-check.yml
vendored
37
.github/workflows/server-check.yml
vendored
@ -1,37 +0,0 @@
|
||||
# Check
|
||||
name: Server Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/server-java
|
||||
- releases/server-java
|
||||
pull_request:
|
||||
branches:
|
||||
- feature/server-java
|
||||
- releases/server-java
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Zulu JDK 8
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "8"
|
||||
|
||||
- name: check with Maven
|
||||
run: mvn clean install -DskipTests -U && mvn pmd:check
|
||||
|
||||
- name: checkstyle with Maven
|
||||
run: mvn checkstyle:check
|
||||
|
||||
- name: test with Maven
|
||||
run: mvn test
|
39
.github/workflows/server-lint.yml
vendored
Normal file
39
.github/workflows/server-lint.yml
vendored
Normal 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
42
.github/workflows/web-lint.yml
vendored
Normal 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
|
26
.gitignore
vendored
26
.gitignore
vendored
@ -1,4 +1,19 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
|
||||
package-lock.json
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
@ -8,11 +23,12 @@
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.iml
|
||||
|
||||
.history
|
||||
|
||||
*/target
|
||||
/logs
|
||||
components.d.ts
|
||||
|
||||
web/*
|
||||
server/*
|
||||
# 默认的上传文件夹
|
||||
userUpload
|
||||
exportfile
|
||||
yarn.lock
|
133
CODE_OF_CONDUCT.md
Normal file
133
CODE_OF_CONDUCT.md
Normal 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
22
DATA_COLLECTION.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Important Disclosure re:XIAOJUSURVEY Data Collection
|
||||
|
||||
XIAOJUSURVEY is open-source software developed and maintained by XIAOJUSURVEY Team and available at https://github.com/didi/xiaoju-survey.
|
||||
We hereby state the purpose and reason for collecting data.
|
||||
|
||||
## Purpose of data collection
|
||||
|
||||
Data collected is used to help improve XIAOJUSURVEY for all users. It is important that our team understands the usage patterns as soon as possible, so we can best decide how to design future features and prioritize current work.
|
||||
|
||||
## Types of data collected
|
||||
|
||||
XIAOJUSURVEY just collects data about version's information. The data collected is subsequently reported to the XIAOJUSURVEY's backend services.
|
||||
|
||||
All data collected will be used exclusively by the XIAOJUSURVEY team for analytical purposes only. The data will be neither accessible nor sold to any third party.
|
||||
|
||||
## Sensitive data
|
||||
|
||||
XIAOJUSURVEY will never collect and/or report sensitive information, such as private keys, API keys, or passwords.
|
||||
|
||||
## How do I opt-in to or opt-out of data sharing?
|
||||
|
||||
See [docs](https://xiaojusurvey.didi.cn/docs/next/community/%E6%95%B0%E6%8D%AE%E4%B8%8A%E6%8A%A5%E5%A3%B0%E6%98%8E) for information on configuring this functionality.
|
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
# 镜像集成
|
||||
FROM node:18-slim
|
||||
|
||||
# 设置工作区间
|
||||
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-only
|
||||
|
||||
# 覆盖nginx配置文件
|
||||
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
RUN cd /xiaoju-survey/server && npm install && npm run build
|
||||
|
||||
# 暴露端口 需要跟nginx的port一致
|
||||
EXPOSE 80
|
||||
|
||||
# docker入口文件,启动nginx和运行pm2启动,并保证监听不断
|
||||
CMD ["sh","docker-run.sh"]
|
314
LICENSE
314
LICENSE
@ -2,180 +2,180 @@
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
@ -186,16 +186,16 @@ APPENDIX: How to apply the Apache License to your work.
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (C) 2023 Beijing Didi Infinity Technology and Development Co.,Ltd. All rights reserved.
|
||||
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.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
177
README.md
177
README.md
@ -0,0 +1,177 @@
|
||||
<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-%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 />
|
||||
|
||||
  **XIAOJUSURVEY**是一套轻量、安全的调研系统,提供面向个人和企业的一站式产品级解决方案,用于构建各类问卷、考试、测评和复杂表单,快速满足各类线上调研场景。
|
||||
|
||||
  内部系统已沉淀 40+种题型,累积精选模板 100+,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,经过上亿量级打磨,沉淀了分题统计、交叉分析、多渠道分析等在线报表能力,快速满足专业化分析。
|
||||
|
||||
# 功能特性
|
||||
|
||||
**🌈 易用**
|
||||
|
||||
- 多类型数据采集,轻松创建调研表单:文本输入、数据选择、评分、投票、文件上传等。
|
||||
|
||||
- 智能逻辑编排,设计多规则动态表单:显示逻辑、跳转逻辑、选项引用、题目引用等。
|
||||
|
||||
- 精细权限管理,支持高效团队协同:空间管理、多角色权限管理等。
|
||||
|
||||
- 数据在线分析和导出,洞察调研结果:数据导出、回收数据管理、分题统计、交叉分析等。
|
||||
|
||||
**🎨 好看**
|
||||
|
||||
- 主题自由定制,适配您的品牌:自定义颜色、背景、图片、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、**企业**和**个人**均可快速构建特定领域的调研类解决方案。
|
||||
|
||||
# 技术
|
||||
|
||||
**1、Web 端:Vue3 + ElementPlus**
|
||||
|
||||
  C 端多端渲染:ReactNative SDK 建设中
|
||||
|
||||
**2、Server 端:NestJS + MongoDB**
|
||||
|
||||
  Java 版:建设中,[欢迎加入共建](https://github.com/didi/xiaoju-survey/issues/306)
|
||||
|
||||
**3、能力增强**
|
||||
|
||||
  在线平台:建设中、智能化问卷:规划中
|
||||
|
||||
# 项目优势
|
||||
|
||||
**一、具备全面的综合性和专业性**
|
||||
|
||||
- [制定了问卷标准化协议规范](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/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/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/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/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/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/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/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/next/document/%E5%B7%A5%E7%A8%8B%E9%83%A8%E7%BD%B2/Docker%E9%83%A8%E7%BD%B2)
|
||||
|
||||
前后端分离,提供 Docker 化方案,提供了完善的部署指导手册。
|
||||
|
||||
# 快速使用
|
||||
|
||||
_(在线平台建设中)_
|
||||
|
||||
# 本地开发
|
||||
|
||||
请查看 [本地安装手册](https://xiaojusurvey.didi.cn/docs/next/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B) 来启动项目。
|
||||
|
||||
# 快速部署
|
||||
|
||||
### 服务部署
|
||||
|
||||
请查看 [快速部署指导](https://xiaojusurvey.didi.cn/docs/next/document/%E5%B7%A5%E7%A8%8B%E9%83%A8%E7%BD%B2/Docker%E9%83%A8%E7%BD%B2) 。
|
||||
|
||||
### 一键部署
|
||||
|
||||
_(手册编写中)_
|
||||
|
||||
<br />
|
||||
|
||||
## Star
|
||||
|
||||
开源不易,如果该项目对你有帮助,请 star 一下 ❤️❤️❤️,你的支持是我们最大的动力。
|
||||
|
||||
[![Star History Chart](https://api.star-history.com/svg?repos=didi/xiaoju-survey&type=Date)](https://star-history.com/#didi/xiaoju-survey&Date)
|
||||
|
||||
## 交流群
|
||||
|
||||
官方群会发布项目最新消息、建设计划和社区活动,欢迎你的加入。
|
||||
|
||||
<img src="https://img-hxy021.didistatic.com/static/starimg/img/KXKvc7sjHz1700061188156.png" width="200" />
|
||||
|
||||
_任何问题和合作可以联系小助手。_
|
||||
|
||||
## 案例
|
||||
|
||||
如果你使用了该项目,请记录反馈:[我在使用](https://github.com/didi/xiaoju-survey/issues/64),你的支持是我们最大的动力。
|
||||
|
||||
## Future Tasks
|
||||
|
||||
[欢迎了解项目发展和共建](https://github.com/didi/xiaoju-survey/issues/85),你的支持是我们最大的动力。
|
||||
|
||||
## 贡献
|
||||
|
||||
如果你想成为贡献者或者扩展技术栈,请查看:[贡献者指南](https://xiaojusurvey.didi.cn/docs/next/share/%E5%A6%82%E4%BD%95%E5%8F%82%E4%B8%8E%E8%B4%A1%E7%8C%AE),你的加入使我们最大的荣幸。
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
关注项目重大变更:[MAJOR CHANGELOG](https://github.com/didi/xiaoju-survey/issues/48)。
|
237
README_EN.md
Normal file
237
README_EN.md
Normal 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 />
|
||||
|
||||
  XIAOJUSURVEY is an enterprises form builder and analytics platform to create questionnaires, exams, polls, quizzes, and analyze data online.
|
||||
|
||||
  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)
|
38
docker-compose.yaml
Normal file
38
docker-compose.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
version: "3.6"
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:4
|
||||
container_name: xiaoju-survey-mongo
|
||||
restart: always
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} # 默认使用系统的环境变量
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} # 默认使用系统的环境变量
|
||||
ports:
|
||||
- "27017:27017" # 数据库端口
|
||||
volumes:
|
||||
- mongo-volume:/data/db # xiaoju-survey-data/db/data:/data/db
|
||||
networks:
|
||||
- xiaoju-survey
|
||||
|
||||
xiaoju-survey:
|
||||
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:80" # API端口
|
||||
environment:
|
||||
XIAOJU_SURVEY_MONGO_URL: mongodb://${MONGO_INITDB_ROOT_USERNAME}:${MONGO_INITDB_ROOT_PASSWORD}@xiaoju-survey-mongo:27017 # docker-compose 会根据容器名称自动处理
|
||||
links:
|
||||
- mongo:mongo
|
||||
depends_on:
|
||||
- mongo
|
||||
networks:
|
||||
- xiaoju-survey
|
||||
|
||||
volumes:
|
||||
mongo-volume:
|
||||
|
||||
networks:
|
||||
xiaoju-survey:
|
||||
name: xiaoju-survey
|
||||
driver: bridge
|
9
docker-run.sh
Normal file
9
docker-run.sh
Normal file
@ -0,0 +1,9 @@
|
||||
#! /bin/bash
|
||||
|
||||
# 启动nginx
|
||||
echo 'nginx start'
|
||||
nginx -g 'daemon on;'
|
||||
|
||||
# 启动后端服务
|
||||
cd /xiaoju-survey/server
|
||||
npm run start:prod
|
@ -1,4 +0,0 @@
|
||||
lombok.accessors.chain=true
|
||||
lombok.equalsAndHashCode.doNotUseGetters=true
|
||||
lombok.toString.doNotUseGetters=true
|
||||
lombok.equalsAndHashCode.callSuper=SKIP
|
68
nginx/nginx.conf
Normal file
68
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
|
||||
}
|
226
pom.xml
226
pom.xml
@ -1,226 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-engine</artifactId>
|
||||
<version>${revision}</version>
|
||||
|
||||
<packaging>pom</packaging>
|
||||
<modules>
|
||||
<module>survey-checkstyle</module>
|
||||
<module>survey-server</module>
|
||||
<module>survey-common</module>
|
||||
<module>survey-dal</module>
|
||||
<module>survey-core</module>
|
||||
<module>survey-extensions</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<java.compiler.source.version>1.8</java.compiler.source.version>
|
||||
<java.compiler.target.version>1.8</java.compiler.target.version>
|
||||
<lombok.version>1.18.20</lombok.version>
|
||||
<junit.version>4.12</junit.version>
|
||||
<lang3.version>3.14.0</lang3.version>
|
||||
<fastjson.version>1.2.83</fastjson.version>
|
||||
<jwt.version>4.4.0</jwt.version>
|
||||
<dozer.version>5.5.1</dozer.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-common</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-dal</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-core</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-extensions</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.auth0</groupId>
|
||||
<artifactId>java-jwt</artifactId>
|
||||
<version>${jwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
<version>${fastjson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>${lang3.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sf.dozer</groupId>
|
||||
<artifactId>dozer</artifactId>
|
||||
<version>${dozer.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</dependencyManagement>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>dev</id>
|
||||
<properties>
|
||||
<spring.profiles.active>dev</spring.profiles.active>
|
||||
</properties>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>test</id>
|
||||
<properties>
|
||||
<spring.profiles.active>test</spring.profiles.active>
|
||||
</properties>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>prod</id>
|
||||
<properties>
|
||||
<spring.profiles.active>prod</spring.profiles.active>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<!-- 定义JDK版本 -->
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>${java.compiler.source.version}</source>
|
||||
<target>${java.compiler.target.version}</target>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-source-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>attach-sources</id>
|
||||
<goals>
|
||||
<goal>jar-no-fork</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- PMD插件 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-pmd-plugin</artifactId>
|
||||
<version>3.16.0</version>
|
||||
<configuration>
|
||||
<verbose>true</verbose>
|
||||
<!-- 规范的配置 -->
|
||||
<rulesets>
|
||||
<ruleset>rulesets/java/ali-concurrent.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-constant.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-exception.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-flowcontrol.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-naming.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-oop.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-orm.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-other.xml</ruleset>
|
||||
<ruleset>rulesets/java/ali-set.xml</ruleset>
|
||||
</rulesets>
|
||||
<printFailingErrors>true</printFailingErrors>
|
||||
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>pmd-check-verify</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.p3c</groupId>
|
||||
<artifactId>p3c-pmd</artifactId>
|
||||
<version>2.1.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle-rule.xml</configLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<logViolationsToConsole>true</logViolationsToConsole>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xiaojusurvey.engine</groupId>
|
||||
<artifactId>survey-checkstyle</artifactId>
|
||||
<version>base</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>8.41.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.19.1</version>
|
||||
<configuration>
|
||||
<skip>false</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
18
server/.env
Normal file
18
server/.env
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
20
server/.env.development
Normal file
20
server/.env.development
Normal 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
|
17
server/.env.production
Normal file
17
server/.env.production
Normal 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
|
25
server/.eslintrc.js
Normal file
25
server/.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
43
server/.gitignore
vendored
Normal file
43
server/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
|
||||
package-lock.json
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
yarn.lock
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
tmp
|
||||
exportfile
|
||||
userUpload
|
4
server/.prettierrc
Normal file
4
server/.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
8
server/nest-cli.json
Normal file
8
server/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
112
server/package.json
Normal file
112
server/package.json
Normal file
@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "xiaoju-survey-server",
|
||||
"version": "1.3.0",
|
||||
"description": "XIAOJUSURVEY的server端",
|
||||
"author": "",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"src/**/__test/*.ts\"",
|
||||
"local": "ts-node ./scripts/run-local.ts",
|
||||
"start": "nest start",
|
||||
"dev": "npm run start:dev",
|
||||
"start:dev": "cross-env NODE_ENV=development nest start --watch",
|
||||
"start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
|
||||
"start:prod": "NODE_ENV=production node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/microservices": "^10.4.4",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/serve-static": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
"ali-oss": "^6.20.0",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"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",
|
||||
"typeorm": "^0.3.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"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": "^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.5.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s",
|
||||
"!**/*.module.ts",
|
||||
"!**/upgrade.*.ts"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.6.0"
|
||||
}
|
||||
}
|
13
server/public/commom.css
Normal file
13
server/public/commom.css
Normal file
@ -0,0 +1,13 @@
|
||||
#main {
|
||||
width: 100wh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
color: #4A4C5B;
|
||||
}
|
BIN
server/public/favicon.ico
Normal file
BIN
server/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
16
server/public/index.html
Normal file
16
server/public/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<img src="/nodata.png" alt="">
|
||||
<p class="title">暂无数据</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
16
server/public/management.html
Normal file
16
server/public/management.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<img src="/nodata.png" alt="">
|
||||
<p class="title">暂无数据</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
BIN
server/public/nodata.png
Normal file
BIN
server/public/nodata.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
16
server/public/render.html
Normal file
16
server/public/render.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<img src="/nodata.png" alt="">
|
||||
<p class="title">暂无数据</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
51
server/scripts/run-local.ts
Normal file
51
server/scripts/run-local.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { spawn } from 'child_process';
|
||||
// import { RedisMemoryServer } from 'redis-memory-server';
|
||||
|
||||
async function startServerAndRunScript() {
|
||||
// 启动 MongoDB 内存服务器
|
||||
const mongod = await MongoMemoryServer.create();
|
||||
const mongoUri = mongod.getUri();
|
||||
|
||||
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',
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
tsnode.stdout?.on('data', (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
|
||||
tsnode.stderr?.on('data', (data) => {
|
||||
console.error(data);
|
||||
});
|
||||
|
||||
tsnode.on('close', async (code) => {
|
||||
console.log(`Nodemon process exited with code ${code}`);
|
||||
await mongod.stop(); // 停止 MongoDB 内存服务器
|
||||
// await redisServer.stop();
|
||||
});
|
||||
}
|
||||
|
||||
startServerAndRunScript().catch((err) => {
|
||||
console.error('Error starting server and script:', err);
|
||||
});
|
18
server/src/app.controller.spec.ts
Normal file
18
server/src/app.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
4
server/src/app.controller.ts
Normal file
4
server/src/app.controller.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {}
|
144
server/src/app.module.ts
Normal file
144
server/src/app.module.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { MiddlewareConsumer, Module } from '@nestjs/common';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
|
||||
import { ResponseSecurityPlugin } from './securityPlugin/responseSecurityPlugin';
|
||||
import { SurveyUtilPlugin } from './securityPlugin/surveyUtilPlugin';
|
||||
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
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';
|
||||
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { HttpExceptionsFilter } from './exceptions/httpExceptions.filter';
|
||||
|
||||
import { Captcha } from './models/captcha.entity';
|
||||
import { User } from './models/user.entity';
|
||||
import { SurveyMeta } from './models/surveyMeta.entity';
|
||||
import { SurveyConf } from './models/surveyConf.entity';
|
||||
import { SurveyHistory } from './models/surveyHistory.entity';
|
||||
import { ResponseSchema } from './models/responseSchema.entity';
|
||||
import { Counter } from './models/counter.entity';
|
||||
import { SurveyResponse } from './models/surveyResponse.entity';
|
||||
import { 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 { PluginManager } from './securityPlugin/pluginManager';
|
||||
import { Logger } from './logger';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: `.env.${process.env.NODE_ENV}`, // 根据 NODE_ENV 动态加载对应的 .env 文件
|
||||
isGlobal: true, // 使配置模块在应用的任何地方可用
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const url = await configService.get<string>('XIAOJU_SURVEY_MONGO_URL');
|
||||
const authSource =
|
||||
(await configService.get<string>(
|
||||
'XIAOJU_SURVEY_MONGO_AUTH_SOURCE',
|
||||
)) || 'admin';
|
||||
const database = await configService.get<string>(
|
||||
'XIAOJU_SURVEY_MONGO_DB_NAME',
|
||||
);
|
||||
return {
|
||||
type: 'mongodb',
|
||||
connectTimeoutMS: 10000,
|
||||
socketTimeoutMS: 10000,
|
||||
url,
|
||||
authSource,
|
||||
useNewUrlParser: true,
|
||||
database,
|
||||
entities: [
|
||||
Captcha,
|
||||
User,
|
||||
SurveyMeta,
|
||||
SurveyConf,
|
||||
SurveyHistory,
|
||||
SurveyResponse,
|
||||
Counter,
|
||||
ResponseSchema,
|
||||
ClientEncrypt,
|
||||
Word,
|
||||
MessagePushingTask,
|
||||
MessagePushingLog,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Collaborator,
|
||||
DownloadTask,
|
||||
Session,
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
AuthModule,
|
||||
SurveyModule,
|
||||
SurveyResponseModule,
|
||||
ServeStaticModule.forRootAsync({
|
||||
useFactory: async () => {
|
||||
return [
|
||||
{
|
||||
rootPath: join(__dirname, '..', 'public'),
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
MessageModule,
|
||||
FileModule,
|
||||
WorkspaceModule,
|
||||
UpgradeModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: HttpExceptionsFilter,
|
||||
},
|
||||
LoggerProvider,
|
||||
PluginManagerProvider,
|
||||
],
|
||||
})
|
||||
export class AppModule {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly pluginManager: PluginManager,
|
||||
) {}
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LogRequestMiddleware).forRoutes('*');
|
||||
}
|
||||
onModuleInit() {
|
||||
this.pluginManager.registerPlugin(
|
||||
new ResponseSecurityPlugin(
|
||||
this.configService.get<string>(
|
||||
'XIAOJU_SURVEY_RESPONSE_AES_ENCRYPT_SECRET_KEY',
|
||||
),
|
||||
),
|
||||
new SurveyUtilPlugin(),
|
||||
);
|
||||
Logger.init({
|
||||
filename: this.configService.get<string>('XIAOJU_SURVEY_LOGGER_FILENAME'),
|
||||
});
|
||||
}
|
||||
}
|
6
server/src/enums/downloadTaskStatus.ts
Normal file
6
server/src/enums/downloadTaskStatus.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export enum DOWNLOAD_TASK_STATUS {
|
||||
WAITING = 'waiting', // 排队中
|
||||
COMPUTING = 'computing', // 计算中
|
||||
SUCCEED = 'succeed', // 导出成功
|
||||
FAILED = 'failed', // 导出失败
|
||||
}
|
4
server/src/enums/encrypt.ts
Normal file
4
server/src/enums/encrypt.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ENCRYPT_TYPE {
|
||||
AES = 'aes',
|
||||
RSA = 'rsa',
|
||||
}
|
27
server/src/enums/exceptionCode.ts
Normal file
27
server/src/enums/exceptionCode.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export enum EXCEPTION_CODE {
|
||||
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, // 上传文件错误
|
||||
}
|
19
server/src/enums/index.ts
Normal file
19
server/src/enums/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// 状态类型
|
||||
export enum RECORD_STATUS {
|
||||
NEW = 'new', // 新建 | 未发布
|
||||
PUBLISHED = 'published', // 发布
|
||||
EDITING = 'editing', // 编辑
|
||||
FINISHED = 'finished', // 已结束
|
||||
REMOVED = 'removed',
|
||||
}
|
||||
|
||||
export const enum RECORD_SUB_STATUS {
|
||||
DEFAULT = '', // 默认
|
||||
PAUSING = 'pausing', // 暂停
|
||||
}
|
||||
|
||||
// 历史类型
|
||||
export enum HISTORY_TYPE {
|
||||
DAILY_HIS = 'dailyHis', //保存历史
|
||||
PUBLISH_HIS = 'publishHis', //发布历史
|
||||
}
|
7
server/src/enums/messagePushing.ts
Normal file
7
server/src/enums/messagePushing.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export enum MESSAGE_PUSHING_TYPE {
|
||||
HTTP = 'http',
|
||||
}
|
||||
|
||||
export enum MESSAGE_PUSHING_HOOK {
|
||||
RESPONSE_INSERTED = 'response_inserted',
|
||||
}
|
37
server/src/enums/question.ts
Normal file
37
server/src/enums/question.ts
Normal 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',
|
||||
}
|
20
server/src/enums/surveyPermission.ts
Normal file
20
server/src/enums/surveyPermission.ts
Normal 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,
|
||||
},
|
||||
};
|
4
server/src/enums/surveySessionStatus.ts
Normal file
4
server/src/enums/surveySessionStatus.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum SESSION_STATUS {
|
||||
ACTIVATED = 'activated',
|
||||
DEACTIVATED = 'deactivated',
|
||||
}
|
41
server/src/enums/workspace.ts
Normal file
41
server/src/enums/workspace.ts
Normal 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,
|
||||
],
|
||||
};
|
55
server/src/exceptions/__test/httpExceptions.filter.spec.ts
Normal file
55
server/src/exceptions/__test/httpExceptions.filter.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
7
server/src/exceptions/authException.ts
Normal file
7
server/src/exceptions/authException.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { HttpException } from './httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
export class AuthenticationException extends HttpException {
|
||||
constructor(public readonly message: string) {
|
||||
super(message, EXCEPTION_CODE.AUTHENTICATION_FAILED);
|
||||
}
|
||||
}
|
10
server/src/exceptions/httpException.ts
Normal file
10
server/src/exceptions/httpException.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
|
||||
export class HttpException extends Error {
|
||||
constructor(
|
||||
public readonly message: string,
|
||||
public readonly code: EXCEPTION_CODE,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
32
server/src/exceptions/httpExceptions.filter.ts
Normal file
32
server/src/exceptions/httpExceptions.filter.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { HttpException } from './httpException';
|
||||
|
||||
@Catch(Error)
|
||||
export class HttpExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal Server Error';
|
||||
let code = 500;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = HttpStatus.OK; // 非系统报错状态码为200
|
||||
message = exception.message;
|
||||
code = exception.code;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
message,
|
||||
code,
|
||||
errmsg: exception.message,
|
||||
});
|
||||
}
|
||||
}
|
8
server/src/exceptions/noPermissionException.ts
Normal file
8
server/src/exceptions/noPermissionException.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { HttpException } from './httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
|
||||
export class NoPermissionException extends HttpException {
|
||||
constructor(public readonly message: string) {
|
||||
super(message, EXCEPTION_CODE.NO_PERMISSION);
|
||||
}
|
||||
}
|
8
server/src/exceptions/surveyNotFoundException.ts
Normal file
8
server/src/exceptions/surveyNotFoundException.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { HttpException } from './httpException';
|
||||
import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
|
||||
|
||||
export class SurveyNotFoundException extends HttpException {
|
||||
constructor(public readonly message: string) {
|
||||
super(message, EXCEPTION_CODE.SURVEY_NOT_FOUND);
|
||||
}
|
||||
}
|
100
server/src/guards/__test/authentication.guard.spec.ts
Normal file
100
server/src/guards/__test/authentication.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
68
server/src/guards/__test/session.guard.spec.ts
Normal file
68
server/src/guards/__test/session.guard.spec.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
198
server/src/guards/__test/survey.guard.spec.ts
Normal file
198
server/src/guards/__test/survey.guard.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
137
server/src/guards/__test/workspace.guard.spec.ts
Normal file
137
server/src/guards/__test/workspace.guard.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
25
server/src/guards/authentication.guard.ts
Normal file
25
server/src/guards/authentication.guard.ts
Normal 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 || '用户凭证错误');
|
||||
}
|
||||
}
|
||||
}
|
30
server/src/guards/session.guard.ts
Normal file
30
server/src/guards/session.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
87
server/src/guards/survey.guard.ts
Normal file
87
server/src/guards/survey.guard.ts
Normal 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('没有权限');
|
||||
}
|
||||
}
|
72
server/src/guards/workspace.guard.ts
Normal file
72
server/src/guards/workspace.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
163
server/src/interfaces/survey.ts
Normal file
163
server/src/interfaces/survey.ts
Normal file
@ -0,0 +1,163 @@
|
||||
// 问卷配置内容定义
|
||||
|
||||
export interface TitleConfig {
|
||||
mainTitle: string;
|
||||
subTitle: string;
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
bgImage: string;
|
||||
videoLink: string;
|
||||
postImg: string;
|
||||
}
|
||||
|
||||
// 问卷头部内容:标题和头图
|
||||
export interface BannerConf {
|
||||
titleConfig: TitleConfig;
|
||||
bannerConfig: BannerConfig;
|
||||
}
|
||||
|
||||
export interface NPS {
|
||||
leftText: string;
|
||||
rightText: string;
|
||||
}
|
||||
|
||||
export interface TextRange {
|
||||
min: {
|
||||
placeholder: string;
|
||||
value: number;
|
||||
};
|
||||
max: {
|
||||
placeholder: string;
|
||||
value: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataItem {
|
||||
isRequired: boolean;
|
||||
showIndex: boolean;
|
||||
showType: boolean;
|
||||
showSpliter: boolean;
|
||||
type: string;
|
||||
valid?: string;
|
||||
field: string;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
randomSort?: boolean;
|
||||
checked: boolean;
|
||||
minNum: string;
|
||||
maxNum: string;
|
||||
star: number;
|
||||
nps?: NPS;
|
||||
placeholderDesc: string;
|
||||
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;
|
||||
others: boolean;
|
||||
mustOthers?: boolean;
|
||||
othersKey?: string;
|
||||
placeholderDesc: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface DataConf {
|
||||
dataList: DataItem[];
|
||||
}
|
||||
|
||||
export interface ConfirmAgain {
|
||||
is_again: boolean;
|
||||
again_text: string;
|
||||
}
|
||||
|
||||
export interface MsgContent {
|
||||
msg_200: string;
|
||||
msg_9001: string;
|
||||
msg_9002: string;
|
||||
msg_9003: string;
|
||||
msg_9004: string;
|
||||
}
|
||||
|
||||
export interface SubmitConf {
|
||||
submitTitle: string;
|
||||
confirmAgain: ConfirmAgain;
|
||||
msgContent: MsgContent;
|
||||
}
|
||||
|
||||
// 白名单类型
|
||||
export enum WhitelistType {
|
||||
ALL = 'ALL',
|
||||
// 空间成员
|
||||
MEMBER = 'MEMBER',
|
||||
// 自定义
|
||||
CUSTOM = 'CUSTOM',
|
||||
}
|
||||
|
||||
// 白名单用户类型
|
||||
export enum MemberType {
|
||||
// 手机号
|
||||
MOBILE = 'MOBILE',
|
||||
// 邮箱
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
|
||||
export interface BaseConf {
|
||||
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 {
|
||||
bannerConf: BannerConf;
|
||||
dataConf: DataConf;
|
||||
submitConf: SubmitConf;
|
||||
baseConf: BaseConf;
|
||||
skinConf: SkinConf;
|
||||
bottomConf: BottomConf;
|
||||
}
|
58
server/src/logger/index.ts
Normal file
58
server/src/logger/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
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 static inited = false;
|
||||
|
||||
constructor(@Inject(CONTEXT) private readonly ctx: RequestContext) {}
|
||||
|
||||
static init(config: { filename: string }) {
|
||||
if (Logger.inited) {
|
||||
return;
|
||||
}
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
app: {
|
||||
type: 'dateFile',
|
||||
filename: config.filename || './logs/app.log',
|
||||
pattern: 'yyyy-MM-dd',
|
||||
alwaysIncludePattern: true,
|
||||
numBackups: 7,
|
||||
layout: {
|
||||
type: 'pattern',
|
||||
pattern: '%m',
|
||||
},
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
default: { appenders: ['app'], level: 'trace' },
|
||||
},
|
||||
});
|
||||
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 traceIdStr = this.ctx?.['traceId']
|
||||
? `traceid=${this.ctx?.['traceId']}||`
|
||||
: '';
|
||||
return log4jsLogger[level](
|
||||
`[${datetime}][${level.toUpperCase()}]${dltag}${traceIdStr}${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
info(message, options?: { dltag?: string }) {
|
||||
return this._log(message, { ...options, level: 'info' });
|
||||
}
|
||||
|
||||
error(message, options?: { dltag?: string }) {
|
||||
return this._log(message, { ...options, level: 'error' });
|
||||
}
|
||||
}
|
8
server/src/logger/logger.provider.ts
Normal file
8
server/src/logger/logger.provider.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
|
||||
import { Logger } from './index';
|
||||
|
||||
export const LoggerProvider: Provider = {
|
||||
provide: Logger,
|
||||
useClass: Logger,
|
||||
};
|
29
server/src/logger/util.ts
Normal file
29
server/src/logger/util.ts
Normal file
@ -0,0 +1,29 @@
|
||||
let count = 999;
|
||||
|
||||
const getCountStr = () => {
|
||||
count++;
|
||||
if (count > 9000) {
|
||||
count = 1000;
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
export const genTraceId = ({ ip }) => {
|
||||
// ip转16位 + 当前时间戳(毫秒级)+自增序列(1000开始自增到9000)+ 当前进程id的后5位
|
||||
ip = ip.replace('::ffff:', '').replace('::1', '');
|
||||
let ipArr;
|
||||
if (ip.indexOf(':') >= 0) {
|
||||
ipArr = ip.split(':').map((segment) => {
|
||||
// 将IPv6每个段转为16位,并补0到长度为4
|
||||
return parseInt(segment, 16).toString(16).padStart(4, '0');
|
||||
});
|
||||
} else {
|
||||
ipArr = ip
|
||||
.split('.')
|
||||
.map((item) =>
|
||||
item ? parseInt(item).toString(16).padStart(2, '0') : '',
|
||||
);
|
||||
}
|
||||
|
||||
return `${ipArr.join('')}${Date.now().toString()}${getCountStr()}${process.pid.toString().slice(-5)}`;
|
||||
};
|
29
server/src/main.ts
Normal file
29
server/src/main.ts
Normal file
@ -0,0 +1,29 @@
|
||||
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}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
37
server/src/middlewares/logRequest.middleware.ts
Normal file
37
server/src/middlewares/logRequest.middleware.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Logger } from '../logger/index'; // 替换为你实际的logger路径
|
||||
import { genTraceId } from '../logger/util';
|
||||
|
||||
@Injectable()
|
||||
export class LogRequestMiddleware implements NestMiddleware {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
const { method, originalUrl, ip } = req;
|
||||
const userAgent = req.get('user-agent') || '';
|
||||
const startTime = Date.now();
|
||||
const traceId = genTraceId({ ip });
|
||||
req['traceId'] = traceId;
|
||||
const query = JSON.stringify(req.query);
|
||||
const body = JSON.stringify(req.body);
|
||||
this.logger.info(
|
||||
`method=${method}||uri=${originalUrl}||ip=${ip}||ua=${userAgent}||query=${query}||body=${body}`,
|
||||
{
|
||||
dltag: 'request_in',
|
||||
},
|
||||
);
|
||||
|
||||
res.once('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.info(
|
||||
`status=${res.statusCode.toString()}||duration=${duration}ms`,
|
||||
{
|
||||
dltag: 'request_out',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
57
server/src/models/__test/surveyMeta.entity.spec.ts
Normal file
57
server/src/models/__test/surveyMeta.entity.spec.ts
Normal 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),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
42
server/src/models/__test/surveyResponse.entity.spec.ts
Normal file
42
server/src/models/__test/surveyResponse.entity.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
13
server/src/models/base.entity.ts
Normal file
13
server/src/models/base.entity.ts
Normal 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;
|
||||
}
|
15
server/src/models/captcha.entity.ts
Normal file
15
server/src/models/captcha.entity.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { BaseEntity } from './base.entity';
|
||||
|
||||
@Entity({ name: 'captcha' })
|
||||
export class Captcha extends BaseEntity {
|
||||
@Index({
|
||||
expireAfterSeconds: 3600,
|
||||
})
|
||||
@ObjectIdColumn()
|
||||
_id: ObjectId;
|
||||
|
||||
@Column()
|
||||
text: string;
|
||||
}
|
23
server/src/models/clientEncrypt.entity.ts
Normal file
23
server/src/models/clientEncrypt.entity.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Entity, Column, Index, ObjectIdColumn } from 'typeorm';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { ENCRYPT_TYPE } from '../enums/encrypt';
|
||||
import { BaseEntity } from './base.entity';
|
||||
|
||||
@Entity({ name: 'clientEncrypt' })
|
||||
export class ClientEncrypt extends BaseEntity {
|
||||
@Index({
|
||||
expireAfterSeconds: 3600,
|
||||
})
|
||||
@ObjectIdColumn()
|
||||
_id: ObjectId;
|
||||
|
||||
@Column('jsonb')
|
||||
data: {
|
||||
secretKey?: string; // aes加密的密钥
|
||||
publicKey?: string; // rsa加密的公钥
|
||||
privateKey?: string; // rsa加密的私钥
|
||||
};
|
||||
|
||||
@Column()
|
||||
type: ENCRYPT_TYPE;
|
||||
}
|
26
server/src/models/collaborator.entity.ts
Normal file
26
server/src/models/collaborator.entity.ts
Normal 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;
|
||||
}
|
17
server/src/models/counter.entity.ts
Normal file
17
server/src/models/counter.entity.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from './base.entity';
|
||||
|
||||
@Entity({ name: 'counter' })
|
||||
export class Counter extends BaseEntity {
|
||||
@Column()
|
||||
key: string;
|
||||
|
||||
@Column()
|
||||
surveyPath: string;
|
||||
|
||||
@Column()
|
||||
type: string;
|
||||
|
||||
@Column('jsonb')
|
||||
data: Record<string, any>;
|
||||
}
|
48
server/src/models/downloadTask.entity.ts
Normal file
48
server/src/models/downloadTask.entity.ts
Normal 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;
|
||||
}
|
17
server/src/models/messagePushingLog.entity.ts
Normal file
17
server/src/models/messagePushingLog.entity.ts
Normal 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状态码
|
||||
}
|
42
server/src/models/messagePushingTask.entity.ts
Normal file
42
server/src/models/messagePushingTask.entity.ts
Normal 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;
|
||||
}
|
34
server/src/models/responseSchema.entity.ts
Normal file
34
server/src/models/responseSchema.entity.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { SurveySchemaInterface } from '../interfaces/survey';
|
||||
import { BaseEntity } from './base.entity';
|
||||
import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums';
|
||||
|
||||
@Entity({ name: 'surveyPublish' })
|
||||
export class ResponseSchema extends BaseEntity {
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column()
|
||||
surveyPath: string;
|
||||
|
||||
@Column('jsonb')
|
||||
code: SurveySchemaInterface;
|
||||
|
||||
@Column()
|
||||
pageId: string;
|
||||
|
||||
@Column()
|
||||
curStatus: {
|
||||
status: RECORD_STATUS;
|
||||
date: number;
|
||||
};
|
||||
|
||||
@Column()
|
||||
subStatus: {
|
||||
status: RECORD_SUB_STATUS;
|
||||
date: number;
|
||||
};
|
||||
|
||||
@Column()
|
||||
isDeleted: boolean;
|
||||
}
|
22
server/src/models/session.entity.ts
Normal file
22
server/src/models/session.entity.ts
Normal 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;
|
||||
}
|
11
server/src/models/surveyConf.entity.ts
Normal file
11
server/src/models/surveyConf.entity.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { SurveySchemaInterface } from '../interfaces/survey';
|
||||
import { BaseEntity } from './base.entity';
|
||||
@Entity({ name: 'surveyConf' })
|
||||
export class SurveyConf extends BaseEntity {
|
||||
@Column('jsonb')
|
||||
code: SurveySchemaInterface;
|
||||
|
||||
@Column()
|
||||
pageId: string;
|
||||
}
|
25
server/src/models/surveyHistory.entity.ts
Normal file
25
server/src/models/surveyHistory.entity.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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 extends BaseEntity {
|
||||
@Column()
|
||||
pageId: string;
|
||||
|
||||
@Column()
|
||||
type: HISTORY_TYPE;
|
||||
|
||||
@Column('jsonb')
|
||||
schema: SurveySchemaInterface;
|
||||
|
||||
@Column('jsonb')
|
||||
operator: {
|
||||
username: string;
|
||||
_id: string;
|
||||
};
|
||||
|
||||
@Column('string')
|
||||
sessionId: string;
|
||||
}
|
83
server/src/models/surveyMeta.entity.ts
Normal file
83
server/src/models/surveyMeta.entity.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { Entity, Column, BeforeInsert } from 'typeorm';
|
||||
import { BaseEntity } from './base.entity';
|
||||
import { RECORD_STATUS, RECORD_SUB_STATUS } from '../enums';
|
||||
|
||||
@Entity({ name: 'surveyMeta' })
|
||||
export class SurveyMeta extends BaseEntity {
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column()
|
||||
remark: string;
|
||||
|
||||
@Column()
|
||||
surveyType: string;
|
||||
|
||||
@Column()
|
||||
surveyPath: string;
|
||||
|
||||
@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();
|
||||
if (!this.curStatus) {
|
||||
const curStatus = { status: RECORD_STATUS.NEW, date: now };
|
||||
this.curStatus = curStatus;
|
||||
this.statusList = [curStatus];
|
||||
}
|
||||
if (!this.subStatus) {
|
||||
const subStatus = { status: RECORD_SUB_STATUS.DEFAULT, date: now };
|
||||
this.subStatus = subStatus;
|
||||
}
|
||||
}
|
||||
}
|
37
server/src/models/surveyResponse.entity.ts
Normal file
37
server/src/models/surveyResponse.entity.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Entity, Column, BeforeInsert, AfterLoad } from 'typeorm';
|
||||
import pluginManager from '../securityPlugin/pluginManager';
|
||||
import { BaseEntity } from './base.entity';
|
||||
|
||||
@Entity({ name: 'surveySubmit' })
|
||||
export class SurveyResponse extends BaseEntity {
|
||||
@Column()
|
||||
pageId: string;
|
||||
|
||||
@Column()
|
||||
surveyPath: string;
|
||||
|
||||
@Column('jsonb')
|
||||
data: Record<string, any>;
|
||||
|
||||
@Column()
|
||||
diffTime: number;
|
||||
|
||||
@Column()
|
||||
clientTime: number;
|
||||
|
||||
@Column('jsonb')
|
||||
secretKeys: Array<string>;
|
||||
|
||||
@Column('jsonb')
|
||||
optionTextAndId: Record<string, any>;
|
||||
|
||||
@BeforeInsert()
|
||||
async onDataInsert() {
|
||||
return await pluginManager.triggerHook('encryptResponseData', this);
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
async onDataLoaded() {
|
||||
return await pluginManager.triggerHook('decryptResponseData', this);
|
||||
}
|
||||
}
|
10
server/src/models/user.entity.ts
Normal file
10
server/src/models/user.entity.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from './base.entity';
|
||||
@Entity({ name: 'user' })
|
||||
export class User extends BaseEntity {
|
||||
@Column()
|
||||
username: string;
|
||||
|
||||
@Column()
|
||||
password: string;
|
||||
}
|
10
server/src/models/word.entity.ts
Normal file
10
server/src/models/word.entity.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from './base.entity';
|
||||
@Entity({ name: 'word' })
|
||||
export class Word extends BaseEntity {
|
||||
@Column()
|
||||
text: string;
|
||||
|
||||
@Column()
|
||||
type: string;
|
||||
}
|
35
server/src/models/workspace.entity.ts
Normal file
35
server/src/models/workspace.entity.ts
Normal 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;
|
||||
}
|
26
server/src/models/workspaceMember.entity.ts
Normal file
26
server/src/models/workspaceMember.entity.ts
Normal 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;
|
||||
}
|
248
server/src/modules/auth/__test/auth.controller.spec.ts
Normal file
248
server/src/modules/auth/__test/auth.controller.spec.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { AuthController } from '../controllers/auth.controller';
|
||||
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 { 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');
|
||||
jest.mock('../services/user.service');
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
let userService: UserService;
|
||||
let captchaService: CaptchaService;
|
||||
let authService: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
// imports: [ConfigModule.forRoot()],
|
||||
controllers: [AuthController],
|
||||
providers: [UserService, CaptchaService, ConfigService, AuthService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
userService = module.get<UserService>(UserService);
|
||||
captchaService = module.get<CaptchaService>(CaptchaService);
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register a user and return a token when captcha is correct', async () => {
|
||||
const mockUserInfo = {
|
||||
username: 'testUser',
|
||||
password: 'testPassword',
|
||||
captchaId: 'testCaptchaId',
|
||||
captcha: 'testCaptcha',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(captchaService, 'checkCaptchaIsCorrect')
|
||||
.mockResolvedValue(true);
|
||||
jest.spyOn(userService, 'createUser').mockResolvedValue(
|
||||
Promise.resolve({
|
||||
username: 'testUser',
|
||||
_id: new ObjectId(),
|
||||
} as User),
|
||||
);
|
||||
jest.spyOn(authService, 'generateToken').mockResolvedValue('testToken');
|
||||
|
||||
const result = await controller.register(mockUserInfo);
|
||||
|
||||
expect(result).toEqual({
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'testToken',
|
||||
username: 'testUser',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw HttpException with CAPTCHA_INCORRECT code when captcha is incorrect', async () => {
|
||||
const mockUserInfo = {
|
||||
username: 'testUser',
|
||||
password: 'testPassword',
|
||||
captchaId: 'testCaptchaId',
|
||||
captcha: 'testCaptcha',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(captchaService, 'checkCaptchaIsCorrect')
|
||||
.mockResolvedValue(false);
|
||||
|
||||
await expect(controller.register(mockUserInfo)).rejects.toThrow(
|
||||
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', () => {
|
||||
it('should login a user and return a token when captcha is correct', async () => {
|
||||
const mockUserInfo = {
|
||||
username: 'testUser',
|
||||
password: 'testPassword',
|
||||
captchaId: 'testCaptchaId',
|
||||
captcha: 'testCaptcha',
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
expect(result).toEqual({
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'testToken',
|
||||
username: 'testUser',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw HttpException with CAPTCHA_INCORRECT code when captcha is incorrect', async () => {
|
||||
const mockUserInfo = {
|
||||
username: 'testUser',
|
||||
password: 'testPassword',
|
||||
captchaId: 'testCaptchaId',
|
||||
captcha: 'testCaptcha',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(captchaService, 'checkCaptchaIsCorrect')
|
||||
.mockResolvedValue(false);
|
||||
|
||||
await expect(controller.login(mockUserInfo)).rejects.toThrow(
|
||||
new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT),
|
||||
);
|
||||
});
|
||||
|
||||
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(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_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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
59
server/src/modules/auth/__test/auth.service.spec.ts
Normal file
59
server/src/modules/auth/__test/auth.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
109
server/src/modules/auth/__test/captcha.service.spec.ts
Normal file
109
server/src/modules/auth/__test/captcha.service.spec.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CaptchaService } from '../services/captcha.service';
|
||||
import { MongoRepository } from 'typeorm';
|
||||
import { Captcha } from 'src/models/captcha.entity';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
describe('CaptchaService', () => {
|
||||
let service: CaptchaService;
|
||||
let captchaRepository: MongoRepository<Captcha>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CaptchaService,
|
||||
{
|
||||
provide: getRepositoryToken(Captcha),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CaptchaService>(CaptchaService);
|
||||
captchaRepository = module.get<MongoRepository<Captcha>>(
|
||||
getRepositoryToken(Captcha),
|
||||
);
|
||||
});
|
||||
|
||||
describe('createCaptcha', () => {
|
||||
it('should create a captcha successfully', async () => {
|
||||
const mockCaptchaText = 'xsfd';
|
||||
jest.spyOn(captchaRepository, 'create').mockImplementation((data) => {
|
||||
return {
|
||||
...data,
|
||||
} as Captcha;
|
||||
});
|
||||
jest.spyOn(captchaRepository, 'save').mockImplementation((data) => {
|
||||
return Promise.resolve({
|
||||
_id: new ObjectId(),
|
||||
...data,
|
||||
} as Captcha);
|
||||
});
|
||||
|
||||
const result = await service.createCaptcha(mockCaptchaText);
|
||||
|
||||
expect(result.text).toBe(mockCaptchaText);
|
||||
expect(captchaRepository.create).toHaveBeenCalledWith({
|
||||
text: mockCaptchaText,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCaptcha', () => {
|
||||
it('should get a captcha by ID successfully', async () => {
|
||||
const mockCaptchaId = new ObjectId();
|
||||
const mockCaptcha = new Captcha();
|
||||
mockCaptcha._id = mockCaptchaId;
|
||||
|
||||
jest
|
||||
.spyOn(captchaRepository, 'findOne')
|
||||
.mockImplementation(() => Promise.resolve(mockCaptcha));
|
||||
|
||||
const result = await service.getCaptcha(mockCaptchaId.toString());
|
||||
|
||||
expect(result).toBe(mockCaptcha);
|
||||
expect(captchaRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { _id: mockCaptchaId },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCaptcha', () => {
|
||||
it('should delete a captcha by ID successfully', async () => {
|
||||
const mockCaptchaId = new ObjectId();
|
||||
|
||||
await service.deleteCaptcha(mockCaptchaId.toString());
|
||||
|
||||
expect(captchaRepository.delete).toHaveBeenCalledWith(mockCaptchaId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkCaptchaIsCorrect', () => {
|
||||
it('should check if captcha is correct successfully', async () => {
|
||||
const mockCaptchaId = new ObjectId();
|
||||
const mockCaptcha = new Captcha();
|
||||
mockCaptcha._id = mockCaptchaId;
|
||||
mockCaptcha.text = 'asfq';
|
||||
jest
|
||||
.spyOn(captchaRepository, 'findOne')
|
||||
.mockImplementation(() => Promise.resolve(mockCaptcha));
|
||||
|
||||
const mockCaptchaData = {
|
||||
captcha: 'asfq',
|
||||
id: mockCaptchaId.toString(),
|
||||
};
|
||||
const result = await service.checkCaptchaIsCorrect(mockCaptchaData);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(captchaRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { _id: mockCaptchaId },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
82
server/src/modules/auth/__test/user.controller.spec.ts
Normal file
82
server/src/modules/auth/__test/user.controller.spec.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
265
server/src/modules/auth/__test/user.service.spec.ts
Normal file
265
server/src/modules/auth/__test/user.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
21
server/src/modules/auth/auth.module.ts
Normal file
21
server/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './services/user.service';
|
||||
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';
|
||||
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule],
|
||||
controllers: [AuthController, UserController],
|
||||
providers: [UserService, AuthService, CaptchaService],
|
||||
exports: [UserService, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
249
server/src/modules/auth/controllers/auth.controller.ts
Normal file
249
server/src/modules/auth/controllers/auth.controller.ts
Normal file
@ -0,0 +1,249 @@
|
||||
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 { 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(
|
||||
private readonly userService: UserService,
|
||||
readonly captchaService: CaptchaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Post('/register')
|
||||
@HttpCode(200)
|
||||
async register(
|
||||
@Body()
|
||||
userInfo: {
|
||||
username: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
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,
|
||||
});
|
||||
|
||||
if (!isCorrect) {
|
||||
throw new HttpException('验证码不正确', EXCEPTION_CODE.CAPTCHA_INCORRECT);
|
||||
}
|
||||
|
||||
const user = await this.userService.createUser({
|
||||
username: userInfo.username,
|
||||
password: userInfo.password,
|
||||
});
|
||||
|
||||
const token = await this.authService.generateToken(
|
||||
{
|
||||
username: user.username,
|
||||
_id: user._id.toString(),
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>(
|
||||
'XIAOJU_SURVEY_JWT_EXPIRES_IN',
|
||||
),
|
||||
},
|
||||
);
|
||||
// 验证过的验证码要删掉,防止被别人保存重复调用
|
||||
this.captchaService.deleteCaptcha(userInfo.captchaId);
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
token,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/login')
|
||||
@HttpCode(200)
|
||||
async login(
|
||||
@Body()
|
||||
userInfo: {
|
||||
username: string;
|
||||
password: string;
|
||||
captchaId: string;
|
||||
captcha: string;
|
||||
},
|
||||
) {
|
||||
const isCorrect = await this.captchaService.checkCaptchaIsCorrect({
|
||||
captcha: userInfo.captcha,
|
||||
id: userInfo.captchaId,
|
||||
});
|
||||
|
||||
if (!isCorrect) {
|
||||
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,
|
||||
});
|
||||
if (user === null) {
|
||||
throw new HttpException(
|
||||
'用户名或密码错误',
|
||||
EXCEPTION_CODE.USER_PASSWORD_WRONG,
|
||||
);
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = await this.authService.generateToken(
|
||||
{
|
||||
username: user.username,
|
||||
_id: user._id.toString(),
|
||||
},
|
||||
{
|
||||
secret: this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
|
||||
expiresIn: this.configService.get<string>(
|
||||
'XIAOJU_SURVEY_JWT_EXPIRES_IN',
|
||||
),
|
||||
},
|
||||
);
|
||||
// 验证过的验证码要删掉,防止被别人保存重复调用
|
||||
this.captchaService.deleteCaptcha(userInfo.captchaId);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'generateToken erro:' +
|
||||
error.message +
|
||||
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET') +
|
||||
this.configService.get<string>('XIAOJU_SURVEY_JWT_EXPIRES_IN'),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
token,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/captcha')
|
||||
@HttpCode(200)
|
||||
async getCaptcha(): Promise<{
|
||||
code: number;
|
||||
data: { id: string; img: string };
|
||||
}> {
|
||||
const captchaData = create({
|
||||
size: 4, // 验证码长度
|
||||
ignoreChars: '0o1i', // 忽略字符
|
||||
noise: 3, // 干扰线数量
|
||||
color: true, // 启用彩色
|
||||
background: '#f0f0f0', // 背景色
|
||||
});
|
||||
const res = await this.captchaService.createCaptcha(captchaData.text);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
id: res._id.toString(),
|
||||
img: captchaData.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码强度
|
||||
*/
|
||||
@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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
65
server/src/modules/auth/controllers/user.controller.ts
Normal file
65
server/src/modules/auth/controllers/user.controller.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
21
server/src/modules/auth/dto/getUserList.dto.ts
Normal file
21
server/src/modules/auth/dto/getUserList.dto.ts
Normal 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);
|
||||
}
|
||||
}
|
38
server/src/modules/auth/services/auth.service.ts
Normal file
38
server/src/modules/auth/services/auth.service.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
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 },
|
||||
) {
|
||||
return sign({ _id, username }, secret, {
|
||||
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;
|
||||
}
|
||||
}
|
36
server/src/modules/auth/services/captcha.service.ts
Normal file
36
server/src/modules/auth/services/captcha.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MongoRepository } from 'typeorm';
|
||||
import { Captcha } from 'src/models/captcha.entity';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
constructor(
|
||||
@InjectRepository(Captcha)
|
||||
private readonly captchaRepository: MongoRepository<Captcha>,
|
||||
) {}
|
||||
|
||||
async createCaptcha(captchaText: string): Promise<Captcha> {
|
||||
const captcha = this.captchaRepository.create({
|
||||
text: captchaText,
|
||||
});
|
||||
|
||||
return this.captchaRepository.save(captcha);
|
||||
}
|
||||
|
||||
async getCaptcha(id: string): Promise<Captcha | undefined> {
|
||||
return this.captchaRepository.findOne({ where: { _id: new ObjectId(id) } });
|
||||
}
|
||||
|
||||
async deleteCaptcha(id: string): Promise<void> {
|
||||
await this.captchaRepository.delete(new ObjectId(id));
|
||||
}
|
||||
|
||||
async checkCaptchaIsCorrect({ captcha, id }) {
|
||||
const captchaData = await this.captchaRepository.findOne({
|
||||
where: { _id: new ObjectId(id) },
|
||||
});
|
||||
return captcha.toLowerCase() === captchaData?.text?.toLowerCase();
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user