init
This commit is contained in:
parent
e6079ed3d5
commit
62bd20c906
|
@ -0,0 +1,19 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。
|
||||
输入要求:
|
||||
1. 用户将提供目标文件名或需要实现的功能描述。
|
||||
2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。
|
||||
任务描述:
|
||||
1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。
|
||||
2. 如果已有文件,检查并基于现有实现完善功能或修复问题。
|
||||
3. 遵循约定的开发框架、语言标准和最佳实践。
|
||||
4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。
|
||||
输出要求:
|
||||
1. 仅返回生成的代码或文件内容。
|
||||
2. 全程使用中文注释
|
||||
3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。
|
||||
4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。
|
||||
</system>
|
|
@ -0,0 +1,30 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 高级软件开发工程师
|
||||
注释目标:
|
||||
1. 顶部注释
|
||||
- 模块/文件整体功能描述
|
||||
2. 类注释
|
||||
- 核心功能概述
|
||||
- 设计模式解析
|
||||
- 使用示例
|
||||
3. 方法/函数注释
|
||||
- 功能详细描述
|
||||
- 输入参数解析
|
||||
- 返回值说明
|
||||
- 异常处理机制
|
||||
4. 代码块注释
|
||||
- 逐行解释代码意图
|
||||
- 关键语句原理阐述
|
||||
- 高级语言特性解读
|
||||
注释风格要求:
|
||||
- 全程使用中文
|
||||
- 专业、清晰、通俗易懂
|
||||
输出约束:
|
||||
- 仅返回添加注释后的代码
|
||||
- 注释与代码完美融合
|
||||
- 保持原代码结构不变
|
||||
</system>
|
|
@ -0,0 +1,6 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
你的任务是基于专业的计算机知识背景剖析代码原理,逐行进行详细分析,充分解释代码意图,并对代码的数据结构,算法或编码方式等进行深度剖析和举例说明,所有分析以中文标准文档型注释的形式插入原代码,除了返回带有分析的代码外,不要返回任何信息.
|
||||
</system>
|
|
@ -0,0 +1,13 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 高级软件开发工程师
|
||||
目标:
|
||||
转换js代码为标准严格的最新typescript代码
|
||||
输出约束:
|
||||
- 仅需返回转换后的代码
|
||||
- 如果不能一次返回,按顺序截断以便继续返回
|
||||
- 保持原代码结构不变
|
||||
</system>
|
|
@ -0,0 +1,52 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 高级软件架构师
|
||||
- 代码质量与性能改进专家
|
||||
|
||||
重构核心目标:
|
||||
1. 代码质量提升
|
||||
- 消除代码坏味道
|
||||
- 提高可读性
|
||||
- 增强可维护性
|
||||
- 优化代码结构
|
||||
|
||||
2. 架构设计优化
|
||||
- 应用合适的设计模式
|
||||
- 提升代码解耦程度
|
||||
- 增强系统扩展性
|
||||
- 改进模块间交互
|
||||
|
||||
3. 性能与资源优化
|
||||
- 算法复杂度改进
|
||||
- 内存使用效率
|
||||
- 计算资源利用率
|
||||
- 减少不必要的计算开销
|
||||
|
||||
4. 健壮性增强
|
||||
- 完善异常处理机制
|
||||
- 增加错误边界保护
|
||||
- 提高代码容错能力
|
||||
- 规范化错误处理流程
|
||||
|
||||
重构原则:
|
||||
- 保持原始功能不变
|
||||
- 遵循SOLID设计原则
|
||||
- 代码简洁性
|
||||
- 高内聚低耦合
|
||||
- 尽量使用语言特性
|
||||
- 避免过度设计
|
||||
|
||||
注释与文档要求:
|
||||
- 保留原有有效注释
|
||||
- 补充专业的中文文档型注释
|
||||
- 解释重构的关键决策
|
||||
- 说明性能与架构改进点
|
||||
|
||||
输出约束:
|
||||
- 仅返回重构后的代码
|
||||
- 保持代码原有风格
|
||||
- 注释清晰专业
|
||||
</system>
|
|
@ -0,0 +1,45 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 专业领域科普作家
|
||||
- 知识传播与教育专家
|
||||
- 多媒体内容策划师
|
||||
写作目标:
|
||||
1. 开篇导读
|
||||
- 话题背景介绍
|
||||
- 阅读难度预期
|
||||
2. 核心概念解析
|
||||
- 专业术语通俗化
|
||||
- 基础原理清晰化
|
||||
- 生活案例类比
|
||||
- 历史发展脉络
|
||||
3. 深度知识传递
|
||||
- 科学原理剖析
|
||||
- 技术发展前沿
|
||||
- 争议观点评述
|
||||
- 实践应用场景
|
||||
4. 互动与延展
|
||||
- 趣味实验设计
|
||||
- 思考问题引导
|
||||
- 扩展阅读推荐
|
||||
- 知识图谱构建
|
||||
写作风格要求:
|
||||
- 全程使用平实的中文
|
||||
- 深入浅出、生动有趣
|
||||
- 严谨专业、符合科学
|
||||
- 分层递进、逻辑清晰
|
||||
输出标准:
|
||||
- 确保内容准确性
|
||||
- 保持叙事连贯性
|
||||
- 突出知识实用性
|
||||
- 强调趣味性与启发性
|
||||
质量控制:
|
||||
- 引用权威来源
|
||||
- 多角度交叉验证
|
||||
输出约束:
|
||||
- 避免过度技术化表达
|
||||
- 规避未经验证的观点
|
||||
- 考虑不同年龄层次需求
|
||||
</system>
|
|
@ -0,0 +1,9 @@
|
|||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
test
|
||||
.md
|
||||
volumes
|
||||
*.tar
|
|
@ -0,0 +1,71 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
backup
|
||||
# dependencies
|
||||
**/node_modules/
|
||||
volumes
|
||||
/.pnp
|
||||
.pnp.js
|
||||
*.tar
|
||||
# testing
|
||||
**/coverage/
|
||||
.env
|
||||
docker-compose.yml
|
||||
packages/common/prisma/migrations
|
||||
packages/common/src/generated
|
||||
# production
|
||||
**/build/
|
||||
**/dist/
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# 快速刷新错误记录
|
||||
.expo/web/cache/development/
|
||||
|
||||
# Expo
|
||||
**/.expo/
|
||||
**/.expo-shared/
|
||||
|
||||
# Android
|
||||
*.apk
|
||||
*.aar
|
||||
*.jks
|
||||
!apps/mobile/android/app/release.jks
|
||||
**/android/.gradle/
|
||||
**/android/app/build/
|
||||
**/android/app/release/
|
||||
**/android/react-native-jsc/build/
|
||||
**/android/react/build/
|
||||
**/android/*/google-services.json
|
||||
**/android/app/src/debug/res/xml/react_native_debug.xml
|
||||
**/android/app/src/dev19/res/xml/react_native_debug.xml
|
||||
**/android/app/src/dev20/res/xml/react_native_debug.xml
|
||||
**/android/app/src/main/assets/shell-app.bundle
|
||||
**/android/app/src/main/res/raw/shell_app_bundle
|
||||
**/android/app/src/release/res/xml/react_native_debug.xml
|
||||
|
||||
# iOS
|
||||
**/ios/Pods/
|
||||
/ios/*.xcworkspace
|
||||
**/ios/DerivedData/
|
||||
**/ios/build/
|
||||
**/ios/Podfile.lock
|
||||
|
||||
# Yarn Plug'n'Play
|
||||
.pnp.*
|
||||
.yarn/cache/
|
||||
.yarn/unplugged/
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
|
||||
# Ignore .idea files in the Expo monorepo
|
||||
**/.idea/
|
||||
|
||||
uploads
|
|
@ -0,0 +1,102 @@
|
|||
# 基础镜像
|
||||
FROM node:20-alpine as base
|
||||
# 更改 apk 镜像源为阿里云
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
# 设置 npm 镜像源
|
||||
RUN yarn config set registry https://registry.npmmirror.com
|
||||
|
||||
# 全局安装 pnpm 并设置其镜像源
|
||||
RUN yarn global add pnpm && pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 pnpm workspace 配置文件
|
||||
COPY pnpm-workspace.yaml ./
|
||||
|
||||
# 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件
|
||||
COPY package*.json pnpm-lock.yaml* ./
|
||||
|
||||
COPY tsconfig.json .
|
||||
# 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install
|
||||
#100-500 5-40
|
||||
|
||||
FROM base As server-build
|
||||
WORKDIR /app
|
||||
COPY packages/common /app/packages/common
|
||||
COPY apps/server /app/apps/server
|
||||
RUN pnpm install --filter server
|
||||
RUN pnpm install --filter common
|
||||
RUN pnpm --filter common generate && pnpm --filter common build:cjs
|
||||
RUN pnpm --filter server build
|
||||
|
||||
FROM base As server-prod-dep
|
||||
WORKDIR /app
|
||||
COPY packages/common /app/packages/common
|
||||
COPY apps/server /app/apps/server
|
||||
RUN pnpm install --filter common --prod
|
||||
RUN pnpm install --filter server --prod
|
||||
|
||||
|
||||
|
||||
FROM server-prod-dep as server
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
COPY --from=server-build /app/packages/common/dist ./packages/common/dist
|
||||
COPY --from=server-build /app/apps/server/dist ./apps/server/dist
|
||||
COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh
|
||||
|
||||
RUN chmod +x ./apps/server/entrypoint.sh
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "/app/apps/server/entrypoint.sh" ]
|
||||
|
||||
|
||||
|
||||
FROM base AS web-build
|
||||
# 复制其余文件到工作目录
|
||||
COPY . .
|
||||
RUN pnpm install
|
||||
RUN pnpm --filter web build
|
||||
|
||||
# 第二阶段,使用 nginx 提供服务
|
||||
FROM nginx:stable-alpine as web
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
# 设置工作目录
|
||||
WORKDIR /usr/share/nginx/html
|
||||
# 设置环境变量
|
||||
ENV NODE_ENV production
|
||||
# 将构建的文件从上一阶段复制到当前镜像中
|
||||
COPY --from=web-build /app/apps/web/dist .
|
||||
# 删除默认的nginx配置文件并添加自定义配置
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d
|
||||
# 添加 entrypoint 脚本,并确保其可执行
|
||||
COPY apps/web/entrypoint.sh /usr/bin/
|
||||
RUN chmod +x /usr/bin/entrypoint.sh
|
||||
# 安装 envsubst 以支持环境变量替换
|
||||
RUN apk add --no-cache gettext
|
||||
# 暴露 80 端口
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["/usr/bin/entrypoint.sh"]
|
||||
|
||||
|
||||
FROM nginx:stable-alpine as nginx
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# 设置环境变量
|
||||
ENV NODE_ENV production
|
||||
|
||||
# 安装 envsubst 以支持环境变量替换
|
||||
RUN apk add --no-cache gettext
|
||||
|
||||
# 暴露 80 端口
|
||||
EXPOSE 80
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
DATABASE_URL="postgresql://root:Letusdoit000@localhost:5432/app?schema=public"
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Letusdoit000
|
||||
TUS_URL=http://localhost:8080
|
||||
JWT_SECRET=/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=
|
||||
PUSH_URL=http://dns:9092
|
||||
PUSH_APPID=123
|
||||
PUSH_APPSECRET=123
|
||||
DEADLINE_CRON="0 0 8 * * *"
|
||||
SERVER_PORT=3000
|
||||
ADMIN_PHONE_NUMBER=13258117304
|
||||
NODE_ENV=development
|
||||
UPLOAD_DIR=/opt/projects/re-mooc/uploads
|
|
@ -0,0 +1,40 @@
|
|||
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',
|
||||
// 允许使用 any 类型
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
// 允许声明但未使用的变量
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all', // 检查所有变量
|
||||
args: 'none', // 不检查函数参数
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 禁止使用未声明的变量
|
||||
'no-undef': 'error',
|
||||
},
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
## Running the app
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ yarn run start
|
||||
|
||||
# watch mode
|
||||
$ yarn run start:dev
|
||||
|
||||
# production mode
|
||||
$ yarn run start:prod
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ yarn run test
|
||||
|
||||
# e2e tests
|
||||
$ yarn run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ yarn run test:cov
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](LICENSE).
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/sh
|
||||
|
||||
# # 从 DATABASE_URL 环境变量中提取主机名、端口和用户名
|
||||
# DB_HOST=$(echo $DATABASE_URL | cut -d '@' -f 2 | cut -d ':' -f 1)
|
||||
# DB_PORT=$(echo $DATABASE_URL | cut -d ':' -f 4 | cut -d '/' -f 1)
|
||||
# DB_USER=$(echo $DATABASE_URL | cut -d '/' -f 3 | cut -d ':' -f 1)
|
||||
|
||||
# # 检查数据库是否就绪
|
||||
# until pg_isready -h $DB_HOST -p $DB_PORT -U $DB_USER; do
|
||||
# echo "Database is unavailable - sleeping"
|
||||
# sleep 1
|
||||
# done
|
||||
|
||||
# echo "Database is up"
|
||||
|
||||
# # 检查标记文件是否存在,如果不存在,则执行 prisma deploy 并创建标记文件
|
||||
# # if [ ! -f "/app/prisma-deployed" ]; then
|
||||
# # pnpm prisma generate
|
||||
# # pnpm prisma migrate deploy
|
||||
# # touch /app/prisma-deployed
|
||||
# # fi
|
||||
|
||||
# # 启动主应用
|
||||
# exec node apps/server/dist/main
|
||||
|
||||
|
||||
# 从 DATABASE_URL 环境变量中提取主机名、端口和用户名
|
||||
DB_HOST=$(echo $DATABASE_URL | cut -d '@' -f 2 | cut -d ':' -f 1)
|
||||
DB_PORT=$(echo $DATABASE_URL | cut -d ':' -f 4 | cut -d '/' -f 1)
|
||||
DB_USER=$(echo $DATABASE_URL | cut -d '/' -f 3 | cut -d ':' -f 1)
|
||||
|
||||
# 检查数据库是否就绪
|
||||
until nc -z $DB_HOST $DB_PORT; do
|
||||
echo "Database is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Database is up"
|
||||
|
||||
# 启动主应用
|
||||
exec node apps/server/dist/main
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "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",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.2.0",
|
||||
"@nestjs/common": "^10.3.10",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.3.10",
|
||||
"@nestjs/schedule": "^4.1.0",
|
||||
"@nestjs/websockets": "^10.3.10",
|
||||
"@nice/common": "workspace:*",
|
||||
"@nice/tus": "workspace:*",
|
||||
"@trpc/server": "11.0.0-rc.456",
|
||||
"argon2": "^0.41.1",
|
||||
"axios": "^1.7.2",
|
||||
"bullmq": "^5.12.0",
|
||||
"cron": "^3.1.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"lib0": "^0.2.97",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"mime-types": "^2.1.35",
|
||||
"minio": "^8.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"nanoid": "^5.0.9",
|
||||
"nanoid-cjs": "^0.0.7",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.5",
|
||||
"superjson-cjs": "^2.2.3",
|
||||
"transliteration": "^2.3.5",
|
||||
"tus-js-client": "^4.1.0",
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"y-leveldb": "^0.1.2",
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/exceljs": "^1.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrpcModule } from './trpc/trpc.module';
|
||||
import { QueueModule } from './queue/queue.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { TaxonomyModule } from './models/taxonomy/taxonomy.module';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { InitModule } from './tasks/init/init.module';
|
||||
import { ReminderModule } from './tasks/reminder/reminder.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { env } from './env';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { MinioModule } from './utils/minio/minio.module';
|
||||
import { WebSocketModule } from './socket/websocket.module';
|
||||
import { CollaborationModule } from './socket/collaboration/collaboration.module';
|
||||
import { ExceptionsFilter } from './filters/exceptions.filter';
|
||||
import { TransformModule } from './models/transform/transform.module';
|
||||
import { RealTimeModule } from './socket/realtime/realtime.module';
|
||||
import { UploadModule } from './upload/upload.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // 全局可用
|
||||
envFilePath: '.env'
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET
|
||||
}),
|
||||
WebSocketModule,
|
||||
TrpcModule,
|
||||
QueueModule,
|
||||
AuthModule,
|
||||
TaxonomyModule,
|
||||
TasksModule,
|
||||
InitModule,
|
||||
ReminderModule,
|
||||
TransformModule,
|
||||
MinioModule,
|
||||
CollaborationModule,
|
||||
RealTimeModule,
|
||||
UploadModule
|
||||
],
|
||||
providers: [{
|
||||
provide: APP_FILTER,
|
||||
useClass: ExceptionsFilter,
|
||||
}],
|
||||
})
|
||||
export class AppModule { }
|
|
@ -0,0 +1,112 @@
|
|||
import {
|
||||
Controller,
|
||||
Headers,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
Req,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthSchema, JwtPayload } from '@nice/common';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { UserProfileService } from './utils';
|
||||
import { z } from 'zod';
|
||||
import { FileValidationErrorType } from './types';
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private logger = new Logger(AuthController.name);
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
@Get('file')
|
||||
async authFileRequset(
|
||||
@Headers('x-original-uri') originalUri: string,
|
||||
@Headers('x-real-ip') realIp: string,
|
||||
@Headers('x-original-method') method: string,
|
||||
@Headers('x-query-params') queryParams: string,
|
||||
@Headers('host') host: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
) {
|
||||
try {
|
||||
const fileRequest = {
|
||||
originalUri,
|
||||
realIp,
|
||||
method,
|
||||
queryParams,
|
||||
host,
|
||||
authorization,
|
||||
};
|
||||
|
||||
const authResult =
|
||||
await this.authService.validateFileRequest(fileRequest);
|
||||
if (!authResult.isValid) {
|
||||
// 使用枚举类型进行错误处理
|
||||
switch (authResult.error) {
|
||||
case FileValidationErrorType.INVALID_URI:
|
||||
throw new BadRequestException(authResult.error);
|
||||
case FileValidationErrorType.RESOURCE_NOT_FOUND:
|
||||
throw new NotFoundException(authResult.error);
|
||||
case FileValidationErrorType.AUTHORIZATION_REQUIRED:
|
||||
case FileValidationErrorType.INVALID_TOKEN:
|
||||
throw new UnauthorizedException(authResult.error);
|
||||
default:
|
||||
throw new InternalServerErrorException(
|
||||
authResult.error || FileValidationErrorType.UNKNOWN_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
headers: {
|
||||
'X-User-Id': authResult.userId,
|
||||
'X-Resource-Type': authResult.resourceType,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.verbose(
|
||||
`File request auth failed from ${realIp} reason:${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('user-profile')
|
||||
async getUserProfile(@Req() request: Request) {
|
||||
const payload: JwtPayload = (request as any).user;
|
||||
const { staff } = await UserProfileService.instance.getUserProfileById(
|
||||
payload.sub,
|
||||
);
|
||||
return staff;
|
||||
}
|
||||
@Post('login')
|
||||
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {
|
||||
return this.authService.signIn(body);
|
||||
}
|
||||
@Post('signup')
|
||||
async signup(@Body() body: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
return this.authService.signUp(body);
|
||||
}
|
||||
@Post('refresh-token')
|
||||
async refreshToken(
|
||||
@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>,
|
||||
) {
|
||||
return this.authService.refreshToken(body);
|
||||
}
|
||||
// @UseGuards(AuthGuard)
|
||||
@Post('logout')
|
||||
async logout(@Body() body: z.infer<typeof AuthSchema.logoutRequest>) {
|
||||
return this.authService.logout(body);
|
||||
}
|
||||
@UseGuards(AuthGuard) // Protecting the changePassword endpoint with AuthGuard
|
||||
@Post('change-password')
|
||||
async changePassword(
|
||||
@Body() body: z.infer<typeof AuthSchema.changePassword>,
|
||||
) {
|
||||
return this.authService.changePassword(body);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { env } from '@server/env';
|
||||
|
||||
import { JwtPayload } from '@nice/common';
|
||||
import { extractTokenFromHeader } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private jwtService: JwtService) { }
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
try {
|
||||
const payload: JwtPayload = await this.jwtService.verifyAsync(
|
||||
token,
|
||||
{
|
||||
secret: env.JWT_SECRET
|
||||
}
|
||||
);
|
||||
request['user'] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { StaffModule } from '@server/models/staff/staff.module';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { RoleMapModule } from '@server/models/rbac/rbac.module';
|
||||
@Module({
|
||||
imports: [StaffModule, RoleMapModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
TrpcService,
|
||||
DepartmentService,
|
||||
SessionService],
|
||||
exports: [AuthService],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule { }
|
|
@ -0,0 +1,243 @@
|
|||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { StaffService } from '../models/staff/staff.service';
|
||||
import { db, AuthSchema, JwtPayload } from '@nice/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import { extractTokenFromAuthorization, UserProfileService } from './utils';
|
||||
import { SessionInfo, SessionService } from './session.service';
|
||||
import { tokenConfig } from './config';
|
||||
import { z } from 'zod';
|
||||
import { FileAuthResult, FileRequest, FileValidationErrorType } from './types';
|
||||
import { TusService } from '@server/upload/tus.service';
|
||||
import { extractFileIdFromNginxUrl } from '@server/upload/utils';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private logger = new Logger(AuthService.name);
|
||||
constructor(
|
||||
private readonly staffService: StaffService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
|
||||
try {
|
||||
// 基础参数验证
|
||||
if (!params?.originalUri) {
|
||||
return { isValid: false, error: FileValidationErrorType.INVALID_URI };
|
||||
}
|
||||
const fileId = extractFileIdFromNginxUrl(params.originalUri);
|
||||
console.log(params.originalUri, fileId);
|
||||
const resource = await db.resource.findFirst({ where: { fileId } });
|
||||
|
||||
// 资源验证
|
||||
if (!resource) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
|
||||
};
|
||||
}
|
||||
// 处理公开资源
|
||||
if (resource.isPublic) {
|
||||
return {
|
||||
isValid: true,
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
}
|
||||
// 处理私有资源
|
||||
const token = extractTokenFromAuthorization(params.authorization);
|
||||
if (!token) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.AUTHORIZATION_REQUIRED,
|
||||
};
|
||||
}
|
||||
const payload: JwtPayload = await this.jwtService.verify(token);
|
||||
if (!payload.sub) {
|
||||
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
userId: payload.sub,
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('File validation error:', error);
|
||||
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
|
||||
}
|
||||
}
|
||||
|
||||
private async generateTokens(payload: JwtPayload): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload, {
|
||||
expiresIn: `${tokenConfig.accessToken.expirationMs / 1000}s`,
|
||||
}),
|
||||
this.jwtService.signAsync(
|
||||
{ sub: payload.sub },
|
||||
{ expiresIn: `${tokenConfig.refreshToken.expirationMs / 1000}s` },
|
||||
),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async signIn(
|
||||
data: z.infer<typeof AuthSchema.signInRequset>,
|
||||
): Promise<SessionInfo> {
|
||||
const { username, password, phoneNumber } = data;
|
||||
|
||||
let staff = await db.staff.findFirst({
|
||||
where: { OR: [{ username }, { phoneNumber }], deletedAt: null },
|
||||
});
|
||||
|
||||
if (!staff && phoneNumber) {
|
||||
staff = await this.signUp({
|
||||
showname: '新用户',
|
||||
username: phoneNumber,
|
||||
phoneNumber,
|
||||
password: phoneNumber,
|
||||
});
|
||||
} else if (!staff) {
|
||||
throw new UnauthorizedException('帐号不存在');
|
||||
}
|
||||
if (!staff.enabled) {
|
||||
throw new UnauthorizedException('帐号已禁用');
|
||||
}
|
||||
const isPasswordMatch =
|
||||
phoneNumber || (await argon2.verify(staff.password, password));
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('帐号或密码错误');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = { sub: staff.id, username: staff.username };
|
||||
const { accessToken, refreshToken } = await this.generateTokens(payload);
|
||||
|
||||
return await this.sessionService.createSession(
|
||||
staff.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
{
|
||||
accessTokenExpirationMs: tokenConfig.accessToken.expirationMs,
|
||||
refreshTokenExpirationMs: tokenConfig.refreshToken.expirationMs,
|
||||
sessionTTL: tokenConfig.accessToken.expirationTTL,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
throw new InternalServerErrorException('创建会话失败');
|
||||
}
|
||||
}
|
||||
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
const { username, phoneNumber, officerId } = data;
|
||||
|
||||
const existingUser = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { officerId }, { phoneNumber }],
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('帐号或证件号已存在');
|
||||
}
|
||||
|
||||
return this.staffService.create({
|
||||
data: {
|
||||
...data,
|
||||
domainId: data.deptId,
|
||||
},
|
||||
});
|
||||
}
|
||||
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
const { refreshToken, sessionId } = data;
|
||||
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify(refreshToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.getSession(
|
||||
payload.sub,
|
||||
sessionId,
|
||||
);
|
||||
if (!session || session.refresh_token !== refreshToken) {
|
||||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const user = await db.staff.findUnique({
|
||||
where: { id: payload.sub, deletedAt: null },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const { accessToken } = await this.generateTokens({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
access_token: accessToken,
|
||||
access_token_expires_at:
|
||||
Date.now() + tokenConfig.accessToken.expirationMs,
|
||||
};
|
||||
await this.sessionService.saveSession(
|
||||
payload.sub,
|
||||
updatedSession,
|
||||
tokenConfig.accessToken.expirationTTL,
|
||||
);
|
||||
await redis.del(
|
||||
UserProfileService.instance.getProfileCacheKey(payload.sub),
|
||||
);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: updatedSession.access_token_expires_at,
|
||||
};
|
||||
}
|
||||
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
|
||||
const { newPassword, phoneNumber, username } = data;
|
||||
const user = await db.staff.findFirst({
|
||||
where: { OR: [{ username }, { phoneNumber }], deletedAt: null },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
await this.staffService.update({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
password: newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: '密码已修改' };
|
||||
}
|
||||
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
|
||||
const { refreshToken, sessionId } = data;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken);
|
||||
await Promise.all([
|
||||
this.sessionService.deleteSession(payload.sub, sessionId),
|
||||
redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)),
|
||||
]);
|
||||
} catch {
|
||||
throw new UnauthorizedException('无效的会话');
|
||||
}
|
||||
|
||||
return { message: '注销成功' };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export const tokenConfig = {
|
||||
accessToken: {
|
||||
expirationMs: 7 * 24 * 3600000, // 7 days
|
||||
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
},
|
||||
refreshToken: {
|
||||
expirationMs: 30 * 24 * 3600000, // 30 days
|
||||
},
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
// session.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
access_token: string;
|
||||
access_token_expires_at: number;
|
||||
refresh_token: string;
|
||||
refresh_token_expires_at: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private getSessionKey(userId: string, sessionId: string): string {
|
||||
return `session-${userId}-${sessionId}`;
|
||||
}
|
||||
async createSession(
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expirationConfig: {
|
||||
accessTokenExpirationMs: number;
|
||||
refreshTokenExpirationMs: number;
|
||||
sessionTTL: number;
|
||||
},
|
||||
): Promise<SessionInfo> {
|
||||
const sessionInfo: SessionInfo = {
|
||||
session_id: uuidv4(),
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs,
|
||||
refresh_token: refreshToken,
|
||||
refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs,
|
||||
};
|
||||
|
||||
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
async getSession(userId: string, sessionId: string): Promise<SessionInfo | null> {
|
||||
const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
|
||||
return sessionData ? JSON.parse(sessionData) : null;
|
||||
}
|
||||
|
||||
async saveSession(
|
||||
userId: string,
|
||||
sessionInfo: SessionInfo,
|
||||
ttl: number,
|
||||
): Promise<void> {
|
||||
await redis.setex(
|
||||
this.getSessionKey(userId, sessionInfo.session_id),
|
||||
ttl,
|
||||
JSON.stringify(sessionInfo),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSession(userId: string, sessionId: string): Promise<void> {
|
||||
await redis.del(this.getSessionKey(userId, sessionId));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
export interface TokenConfig {
|
||||
accessToken: {
|
||||
expirationMs: number;
|
||||
expirationTTL: number;
|
||||
};
|
||||
refreshToken: {
|
||||
expirationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileAuthResult {
|
||||
isValid: boolean
|
||||
userId?: string
|
||||
resourceType?: string
|
||||
error?: string
|
||||
}
|
||||
export interface FileRequest {
|
||||
originalUri: string;
|
||||
realIp: string;
|
||||
method: string;
|
||||
queryParams: string;
|
||||
host: string;
|
||||
authorization: string
|
||||
}
|
||||
export enum FileValidationErrorType {
|
||||
INVALID_URI = 'INVALID_URI',
|
||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
||||
AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED',
|
||||
INVALID_TOKEN = 'INVALID_TOKEN',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
|
||||
}
|
|
@ -0,0 +1,193 @@
|
|||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
JwtPayload,
|
||||
RolePerms,
|
||||
ObjectType,
|
||||
} from '@nice/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { env } from '@server/env';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
import { RoleMapService } from '@server/models/rbac/rolemap.service';
|
||||
import { Request } from "express"
|
||||
interface ProfileResult {
|
||||
staff: UserProfile | undefined;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TokenVerifyResult {
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
export function extractTokenFromHeader(request: Request): string | undefined {
|
||||
return extractTokenFromAuthorization(request.headers.authorization)
|
||||
}
|
||||
export function extractTokenFromAuthorization(authorization: string): string | undefined {
|
||||
const [type, token] = authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
export class UserProfileService {
|
||||
public static readonly instance = new UserProfileService();
|
||||
|
||||
private readonly CACHE_TTL = 3600; // 缓存时间1小时
|
||||
private readonly jwtService: JwtService;
|
||||
private readonly departmentService: DepartmentService;
|
||||
private readonly roleMapService: RoleMapService;
|
||||
|
||||
private constructor() {
|
||||
this.jwtService = new JwtService();
|
||||
this.departmentService = new DepartmentService();
|
||||
this.roleMapService = new RoleMapService(this.departmentService);
|
||||
EventBus.on("dataChanged", ({ type, data }) => {
|
||||
if (type === ObjectType.STAFF) {
|
||||
// 确保 data 是数组,如果不是则转换为数组
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
for (const item of dataArray) {
|
||||
if (item.id) {
|
||||
redis.del(this.getProfileCacheKey(item.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
public getProfileCacheKey(id: string) {
|
||||
return `user-profile-${id}`;
|
||||
}
|
||||
/**
|
||||
* 验证并解析token
|
||||
*/
|
||||
public async verifyToken(token?: string): Promise<TokenVerifyResult> {
|
||||
if (!token) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const { sub: id } = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: env.JWT_SECRET,
|
||||
});
|
||||
return { id };
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Token verification failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过Token获取用户信息
|
||||
*/
|
||||
public async getUserProfileByToken(token?: string): Promise<ProfileResult> {
|
||||
const { id, error } = await this.verifyToken(token);
|
||||
if (error) {
|
||||
return {
|
||||
staff: undefined,
|
||||
error,
|
||||
};
|
||||
}
|
||||
return await this.getUserProfileById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过用户ID获取用户信息
|
||||
*/
|
||||
public async getUserProfileById(id?: string): Promise<ProfileResult> {
|
||||
if (!id) {
|
||||
return { staff: undefined };
|
||||
}
|
||||
try {
|
||||
const cachedProfile = await this.getCachedProfile(id);
|
||||
if (cachedProfile) {
|
||||
return { staff: cachedProfile };
|
||||
}
|
||||
const staff = await this.getBaseProfile(id);
|
||||
if (!staff) {
|
||||
throw new Error(`User with id ${id} does not exist`);
|
||||
}
|
||||
|
||||
await this.populateStaffExtras(staff);
|
||||
await this.cacheProfile(id, staff);
|
||||
|
||||
return { staff };
|
||||
} catch (error) {
|
||||
return {
|
||||
staff: undefined,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to get user profile',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取用户信息
|
||||
*/
|
||||
private async getCachedProfile(id: string): Promise<UserProfile | null> {
|
||||
const cachedData = await redis.get(this.getProfileCacheKey(id));
|
||||
if (!cachedData) return null;
|
||||
|
||||
try {
|
||||
const profile = JSON.parse(cachedData) as UserProfile;
|
||||
return profile.id === id ? profile : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存用户信息
|
||||
*/
|
||||
private async cacheProfile(id: string, profile: UserProfile): Promise<void> {
|
||||
await redis.set(
|
||||
this.getProfileCacheKey(id),
|
||||
JSON.stringify(profile),
|
||||
'EX',
|
||||
this.CACHE_TTL,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础用户信息
|
||||
*/
|
||||
private async getBaseProfile(id: string): Promise<UserProfile | null> {
|
||||
return (await db.staff.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
deptId: true,
|
||||
department: true,
|
||||
domainId: true,
|
||||
domain: true,
|
||||
showname: true,
|
||||
username: true,
|
||||
phoneNumber: true,
|
||||
},
|
||||
})) as unknown as UserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充用户权限信息
|
||||
*/
|
||||
private async populateStaffExtras(staff: UserProfile): Promise<void> {
|
||||
const [deptIds, parentDeptIds, permissions] = await Promise.all([
|
||||
staff.deptId
|
||||
? this.departmentService.getDescendantIdsInDomain(staff.deptId)
|
||||
: [],
|
||||
staff.deptId
|
||||
? this.departmentService.getAncestorIds([staff.deptId])
|
||||
: [],
|
||||
this.roleMapService.getPermsForObject({
|
||||
domainId: staff.domainId,
|
||||
staffId: staff.id,
|
||||
deptId: staff.deptId,
|
||||
}) as Promise<RolePerms[]>,
|
||||
]);
|
||||
|
||||
Object.assign(staff, {
|
||||
deptIds,
|
||||
parentDeptIds,
|
||||
permissions,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export const env: { JWT_SECRET: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA='
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
@Catch()
|
||||
export class ExceptionsFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.message
|
||||
: 'Internal server error';
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { TrpcRouter } from './trpc/trpc.router';
|
||||
import { WebSocketService } from './socket/websocket.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 启用 CORS 并允许所有来源
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
});
|
||||
const wsService = app.get(WebSocketService);
|
||||
await wsService.initialize(app.getHttpServer());
|
||||
const trpc = app.get(TrpcRouter);
|
||||
trpc.applyMiddleware(app);
|
||||
|
||||
const port = process.env.SERVER_PORT || 3000;
|
||||
|
||||
await app.listen(port);
|
||||
}
|
||||
bootstrap();
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { AppConfigRouter } from './app-config.router';
|
||||
import { RealTimeModule } from '@server/socket/realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [RealTimeModule],
|
||||
providers: [AppConfigService, AppConfigRouter, TrpcService],
|
||||
exports: [AppConfigService, AppConfigRouter]
|
||||
})
|
||||
export class AppConfigModule { }
|
|
@ -0,0 +1,47 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { Prisma } from '@nice/common';
|
||||
import { RealtimeServer } from '@server/socket/realtime/realtime.server';
|
||||
const AppConfigUncheckedCreateInputSchema: ZodType<Prisma.AppConfigUncheckedCreateInput> = z.any()
|
||||
const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any()
|
||||
const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> = z.any()
|
||||
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> = z.any()
|
||||
@Injectable()
|
||||
export class AppConfigRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly realtimeServer: RealtimeServer
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(AppConfigUncheckedCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.appConfigService.create({ data: input });
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(AppConfigUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
const { staff } = ctx;
|
||||
return await this.appConfigService.update(input);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure.input(AppConfigDeleteManyArgsSchema).mutation(async ({ input }) => {
|
||||
return await this.appConfigService.deleteMany(input)
|
||||
}),
|
||||
findFirst: this.trpc.protectProcedure.input(AppConfigFindFirstArgsSchema).
|
||||
query(async ({ input }) => {
|
||||
|
||||
return await this.appConfigService.findFirst(input)
|
||||
}),
|
||||
clearRowCache: this.trpc.protectProcedure.mutation(async () => {
|
||||
return await this.appConfigService.clearRowCache()
|
||||
}),
|
||||
getClientCount: this.trpc.protectProcedure.query(() => {
|
||||
return this.realtimeServer.getClientCount()
|
||||
})
|
||||
});
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
} from '@nice/common';
|
||||
|
||||
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||
|
||||
@Injectable()
|
||||
export class AppConfigService extends BaseService<Prisma.AppConfigDelegate> {
|
||||
constructor() {
|
||||
super(db, "appConfig");
|
||||
}
|
||||
async clearRowCache() {
|
||||
await deleteByPattern("row-*")
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,577 @@
|
|||
import { db, Prisma, PrismaClient } from '@nice/common';
|
||||
import {
|
||||
Operations,
|
||||
DelegateArgs,
|
||||
DelegateReturnTypes,
|
||||
DataArgs,
|
||||
WhereArgs,
|
||||
DelegateFuncs,
|
||||
UpdateOrderArgs,
|
||||
TransactionType,
|
||||
SelectArgs,
|
||||
} from './base.type';
|
||||
import {
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { ERROR_MAP, operationT, PrismaErrorCode } from './errorMap.prisma';
|
||||
|
||||
/**
|
||||
* BaseService provides a generic CRUD interface for a prisma model.
|
||||
* It enables common data operations such as find, create, update, and delete.
|
||||
*
|
||||
* @template D - Type for the model delegate, defining available operations.
|
||||
* @template A - Arguments for the model delegate's operations.
|
||||
* @template R - Return types for the model delegate's operations.
|
||||
*/
|
||||
export class BaseService<
|
||||
D extends DelegateFuncs,
|
||||
A extends DelegateArgs<D> = DelegateArgs<D>,
|
||||
R extends DelegateReturnTypes<D> = DelegateReturnTypes<D>,
|
||||
> {
|
||||
protected ORDER_INTERVAL = 100;
|
||||
/**
|
||||
* Initializes the BaseService with the specified model.
|
||||
* @param model - The Prisma model delegate for database operations.
|
||||
*/
|
||||
constructor(
|
||||
protected prisma: PrismaClient,
|
||||
protected objectType: string,
|
||||
protected enableOrder: boolean = false,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieves the name of the model dynamically.
|
||||
* @returns {string} - The name of the model.
|
||||
*/
|
||||
private getModelName(): string {
|
||||
const modelName = this.getModel().constructor.name;
|
||||
return modelName;
|
||||
}
|
||||
private getModel(tx?: TransactionType): D {
|
||||
return tx?.[this.objectType] || (this.prisma[this.objectType] as D);
|
||||
}
|
||||
/**
|
||||
* Error handling helper function
|
||||
*/
|
||||
private handleError(error: any, operation: operationT): never {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
const handler = ERROR_MAP[error.code as PrismaErrorCode];
|
||||
if (handler) {
|
||||
throw handler(
|
||||
operation,
|
||||
error?.meta || {
|
||||
target: 'record',
|
||||
model: this.getModelName(),
|
||||
},
|
||||
);
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`Database error: ${error.message}`,
|
||||
);
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
`Unexpected error: ${error.message || 'Unknown error occurred.'}`,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Finds a unique record by given criteria.
|
||||
* @param args - Arguments to find a unique record.
|
||||
* @returns {Promise<R['findUnique']>} - A promise resolving to the found record.
|
||||
* @example
|
||||
* const user = await service.findUnique({ where: { id: 'user_id' } });
|
||||
*/
|
||||
async findUnique(args: A['findUnique']): Promise<R['findUnique']> {
|
||||
try {
|
||||
return this.getModel().findUnique(args as any) as Promise<
|
||||
R['findUnique']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Finds the first record matching the given criteria.
|
||||
* @param args - Arguments to find the first matching record.
|
||||
* @returns {Promise<R['findFirst']>} - A promise resolving to the first matching record.
|
||||
* @example
|
||||
* const firstUser = await service.findFirst({ where: { name: 'John' } });
|
||||
*/
|
||||
async findFirst(args: A['findFirst']): Promise<R['findFirst']> {
|
||||
try {
|
||||
return this.getModel().findFirst(args as any) as Promise<R['findFirst']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a record by its ID.
|
||||
* @param id - The ID of the record to find.
|
||||
* @param args - Optional additional arguments for the find operation.
|
||||
* @returns {Promise<R['findFirst']>} - A promise resolving to the found record.
|
||||
* @throws {NotFoundException} - If no record is found with the given ID.
|
||||
* @example
|
||||
* const user = await service.findById('user_id');
|
||||
*/
|
||||
async findById(id: string, args?: A['findFirst']): Promise<R['findFirst']> {
|
||||
try {
|
||||
const record = (await this.getModel().findFirst({
|
||||
where: { id },
|
||||
...(args || {}),
|
||||
})) as R['findFirst'];
|
||||
if (!record) {
|
||||
throw new NotFoundException(`Record with ID ${id} not found.`);
|
||||
}
|
||||
return record;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds multiple records matching the given criteria.
|
||||
* @param args - Arguments to find multiple records.
|
||||
* @returns {Promise<R['findMany']>} - A promise resolving to the list of found records.
|
||||
* @example
|
||||
* const users = await service.findMany({ where: { isActive: true } });
|
||||
*/
|
||||
async findMany(args: A['findMany']): Promise<R['findMany']> {
|
||||
try {
|
||||
return this.getModel().findMany(args as any) as Promise<R['findMany']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new record with the given data.
|
||||
* @param args - Arguments to create a record.
|
||||
* @returns {Promise<R['create']>} - A promise resolving to the created record.
|
||||
* @example
|
||||
* const newUser = await service.create({ data: { name: 'John Doe' } });
|
||||
*/
|
||||
async create(args: A['create'], params?: any): Promise<R['create']> {
|
||||
try {
|
||||
if (this.enableOrder && !(args as any).data.order) {
|
||||
// 查找当前最大的 order 值
|
||||
const maxOrderItem = (await this.getModel(params?.tx).findFirst({
|
||||
orderBy: { order: 'desc' },
|
||||
})) as any;
|
||||
// 设置新记录的 order 值
|
||||
const newOrder = maxOrderItem
|
||||
? maxOrderItem.order + this.ORDER_INTERVAL
|
||||
: 1;
|
||||
// 将 order 添加到创建参数中
|
||||
(args as any).data.order = newOrder;
|
||||
}
|
||||
return this.getModel(params?.tx).create(args as any) as Promise<
|
||||
R['create']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates multiple new records with the given data.
|
||||
* @param args - Arguments to create multiple records.
|
||||
* @returns {Promise<R['createMany']>} - A promise resolving to the created records.
|
||||
* @example
|
||||
* const newUsers = await service.createMany({ data: [{ name: 'John' }, { name: 'Jane' }] });
|
||||
*/
|
||||
async createMany(
|
||||
args: A['createMany'],
|
||||
params?: any,
|
||||
): Promise<R['createMany']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).createMany(args as any) as Promise<
|
||||
R['createMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record with the given data.
|
||||
* @param args - Arguments to update a record.
|
||||
* @returns {Promise<R['update']>} - A promise resolving to the updated record.
|
||||
* @example
|
||||
* const updatedUser = await service.update({ where: { id: 'user_id' }, data: { name: 'John' } });
|
||||
*/
|
||||
async update(args: A['update'], params?: any): Promise<R['update']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).update(args as any) as Promise<
|
||||
R['update']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record by ID with the given data.
|
||||
* @param id - The ID of the record to update.
|
||||
* @param data - The data to update the record with.
|
||||
* @returns {Promise<R['update']>} - A promise resolving to the updated record.
|
||||
* @example
|
||||
* const updatedUser = await service.updateById('user_id', { name: 'John Doe' });
|
||||
*/
|
||||
async updateById(
|
||||
id: string,
|
||||
data: DataArgs<A['update']>,
|
||||
): Promise<R['update']> {
|
||||
try {
|
||||
return (await this.getModel().update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
})) as R['update'];
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record by ID.
|
||||
* @param id - The ID of the record to delete.
|
||||
* @returns {Promise<R['delete']>} - A promise resolving to the deleted record.
|
||||
* @example
|
||||
* const deletedUser = await service.deleteById('user_id');
|
||||
*/
|
||||
async deleteById(id: string): Promise<R['delete']> {
|
||||
try {
|
||||
return (await this.getModel().delete({
|
||||
where: { id },
|
||||
})) as R['delete'];
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record based on the given criteria.
|
||||
* @param args - Arguments to delete a record.
|
||||
* @returns {Promise<R['delete']>} - A promise resolving to the deleted record.
|
||||
* @example
|
||||
* const deletedUser = await service.delete({ where: { name: 'John' } });
|
||||
*/
|
||||
async delete(args: A['delete'], params?: any): Promise<R['delete']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).delete(args as any) as Promise<
|
||||
R['delete']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a record based on the given criteria.
|
||||
* @param args - Arguments to upsert a record.
|
||||
* @returns {Promise<R['upsert']>} - A promise resolving to the created or updated record.
|
||||
* @example
|
||||
* const user = await service.upsert({ where: { id: 'user_id' }, create: { name: 'John' }, update: { name: 'Johnny' } });
|
||||
*/
|
||||
async upsert(args: A['upsert']): Promise<R['upsert']> {
|
||||
try {
|
||||
return this.getModel().upsert(args as any) as Promise<R['upsert']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of records matching the given criteria.
|
||||
* @param args - Arguments to count records.
|
||||
* @returns {Promise<R['count']>} - A promise resolving to the count.
|
||||
* @example
|
||||
* const userCount = await service.count({ where: { isActive: true } });
|
||||
*/
|
||||
async count(args: A['count']): Promise<R['count']> {
|
||||
try {
|
||||
return this.getModel().count(args as any) as Promise<R['count']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates records based on the given criteria.
|
||||
* @param args - Arguments to aggregate records.
|
||||
* @returns {Promise<R['aggregate']>} - A promise resolving to the aggregation result.
|
||||
* @example
|
||||
* const userAggregates = await service.aggregate({ _count: true });
|
||||
*/
|
||||
async aggregate(args: A['aggregate']): Promise<R['aggregate']> {
|
||||
try {
|
||||
return this.getModel().aggregate(args as any) as Promise<R['aggregate']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple records based on the given criteria.
|
||||
* @param args - Arguments to delete multiple records.
|
||||
* @returns {Promise<R['deleteMany']>} - A promise resolving to the result of the deletion.
|
||||
* @example
|
||||
* const deleteResult = await service.deleteMany({ where: { isActive: false } });
|
||||
*/
|
||||
async deleteMany(
|
||||
args: A['deleteMany'],
|
||||
params?: any,
|
||||
): Promise<R['deleteMany']> {
|
||||
try {
|
||||
return this.getModel(params?.tx).deleteMany(args as any) as Promise<
|
||||
R['deleteMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple records based on the given criteria.
|
||||
* @param args - Arguments to update multiple records.
|
||||
* @returns {Promise<R['updateMany']>} - A promise resolving to the result of the update.
|
||||
* @example
|
||||
* const updateResult = await service.updateMany({ where: { isActive: true }, data: { isActive: false } });
|
||||
*/
|
||||
async updateMany(args: A['updateMany']): Promise<R['updateMany']> {
|
||||
try {
|
||||
return this.getModel().updateMany(args as any) as Promise<
|
||||
R['updateMany']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a record by unique criteria or creates it if not found.
|
||||
* @param args - Arguments to find or create a record.
|
||||
* @returns {Promise<R['findUnique'] | R['create']>} - A promise resolving to the found or created record.
|
||||
* @example
|
||||
* const user = await service.findOrCreate({ where: { email: 'john@example.com' }, create: { email: 'john@example.com', name: 'John' } });
|
||||
*/
|
||||
async findOrCreate(args: {
|
||||
where: WhereArgs<A['findUnique']>;
|
||||
create: DataArgs<A['create']>;
|
||||
}): Promise<R['findUnique'] | R['create']> {
|
||||
try {
|
||||
const existing = (await this.getModel().findUnique({
|
||||
where: args.where,
|
||||
} as any)) as R['findUnique'];
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return this.getModel().create({ data: args.create } as any) as Promise<
|
||||
R['create']
|
||||
>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a record exists based on the given criteria.
|
||||
* @param where - The criteria to check for existence.
|
||||
* @returns {Promise<boolean>} - A promise resolving to true if the record exists, false otherwise.
|
||||
* @example
|
||||
* const exists = await service.exists({ email: 'john@example.com' });
|
||||
*/
|
||||
async exists(where: WhereArgs<A['findUnique']>): Promise<boolean> {
|
||||
try {
|
||||
const count = (await this.getModel().count({ where } as any)) as number;
|
||||
return count > 0;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft deletes records by setting `isDeleted` to true for the given IDs.
|
||||
* @param ids - An array of IDs of the records to soft delete.
|
||||
* @param data - Additional data to update on soft delete. (Optional)
|
||||
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
|
||||
* @example
|
||||
* const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' });
|
||||
*/
|
||||
async softDeleteByIds(
|
||||
ids: string[],
|
||||
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
|
||||
): Promise<R['update'][]> {
|
||||
try {
|
||||
if (!ids || ids.length === 0) {
|
||||
return []; // Return empty array if no IDs are provided
|
||||
}
|
||||
return this.getModel().updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { ...data, deletedAt: new Date() } as any,
|
||||
}) as Promise<R['update'][]>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'delete');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Restores soft-deleted records by setting `isDeleted` to false for the given IDs.
|
||||
* @param ids - An array of IDs of the records to restore.
|
||||
* @param data - Additional data to update on restore. (Optional)
|
||||
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
|
||||
* @example
|
||||
* const restoredUsers = await service.restoreByIds(['user_id1', 'user_id2'], { restoredBy: 'admin' });
|
||||
*/
|
||||
async restoreByIds(
|
||||
ids: string[],
|
||||
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
|
||||
): Promise<R['update'][]> {
|
||||
try {
|
||||
if (!ids || ids.length === 0) {
|
||||
return []; // Return empty array if no IDs are provided
|
||||
}
|
||||
return this.getModel().updateMany({
|
||||
where: { id: { in: ids }, isDeleted: true }, // Only restore soft-deleted records
|
||||
data: { ...data, deletedAt: null } as any,
|
||||
}) as Promise<R['update'][]>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds multiple records with pagination.
|
||||
* @param args - Arguments including page, pageSize, and optional filters.
|
||||
* @returns {Promise<R['findMany']>} - A promise resolving to the paginated list of records.
|
||||
* @example
|
||||
* const users = await service.findManyWithPagination({ page: 1, pageSize: 10, where: { isActive: true } });
|
||||
*/
|
||||
async findManyWithPagination(args: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
where?: WhereArgs<A['findMany']>;
|
||||
select?: SelectArgs<A['findMany']>;
|
||||
}): Promise<{ items: R['findMany']; totalPages: number }> {
|
||||
const { page = 1, pageSize = 10, where, select } = args;
|
||||
|
||||
try {
|
||||
// 获取总记录数
|
||||
const total = (await this.getModel().count({ where })) as number;
|
||||
// 获取分页数据
|
||||
const items = (await this.getModel().findMany({
|
||||
where,
|
||||
select,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
} as any)) as R['findMany'];
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return {
|
||||
items,
|
||||
totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 基于游标的分页查询方法
|
||||
* @description 该方法实现了基于游标的分页查询,相比传统的offset/limit分页有更好的性能
|
||||
* @param args 查询参数对象,包含cursor、take、where、orderBy、select等字段
|
||||
* @returns 返回查询结果对象,包含items数组、总数和下一页游标
|
||||
*/
|
||||
async findManyWithCursor(
|
||||
args: A['findMany'],
|
||||
): Promise<{ items: R['findMany']; nextCursor: string | null }> {
|
||||
// 解构查询参数,设置默认每页取10条记录
|
||||
const { cursor, take = 6, where, orderBy, select } = args as any;
|
||||
|
||||
try {
|
||||
const items = (await this.getModel().findMany({
|
||||
where: where,
|
||||
orderBy: [{ ...orderBy }, { updatedAt: 'desc' }, { id: 'desc' }],
|
||||
select,
|
||||
take: take + 1,
|
||||
cursor: cursor
|
||||
? { updatedAt: cursor.split('_')[0], id: cursor.split('_')[1] }
|
||||
: undefined,
|
||||
} as any)) as any[];
|
||||
|
||||
/**
|
||||
* 处理下一页游标
|
||||
* @description
|
||||
* 1. 如果查到的记录数超过take,说明还有下一页
|
||||
* 2. 将最后一条记录弹出,用其updatedAt和id构造下一页游标
|
||||
* 3. 游标格式为: updatedAt_id
|
||||
*/
|
||||
let nextCursor: string | null = '';
|
||||
if (items.length > take) {
|
||||
const nextItem = items.pop();
|
||||
nextCursor = `${nextItem!.updatedAt?.toISOString()}_${nextItem!.id}`;
|
||||
}
|
||||
if (nextCursor === '') {
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回查询结果
|
||||
* @returns {Object}
|
||||
* - items: 当前页记录
|
||||
* - totalCount: 总记录数
|
||||
* - nextCursor: 下一页游标
|
||||
*/
|
||||
return {
|
||||
items: items as R['findMany'],
|
||||
nextCursor: nextCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrder(args: UpdateOrderArgs) {
|
||||
const { id, overId } = args;
|
||||
const [currentObject, targetObject] = (await Promise.all([
|
||||
this.findFirst({ where: { id } } as any),
|
||||
this.findFirst({ where: { id: overId } } as any),
|
||||
])) as any;
|
||||
if (!currentObject || !targetObject) {
|
||||
throw new Error('Invalid object or target object');
|
||||
}
|
||||
const nextObject = (await this.findFirst({
|
||||
where: {
|
||||
order: { gt: targetObject.order },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
} as any)) as any;
|
||||
|
||||
const newOrder = nextObject
|
||||
? (targetObject.order + nextObject.order) / 2
|
||||
: targetObject.order + this.ORDER_INTERVAL;
|
||||
return this.update({ where: { id }, data: { order: newOrder } } as any);
|
||||
}
|
||||
/**
|
||||
* Wraps the result of a database operation with a transformation function.
|
||||
* @template T - The type of the result to be transformed.
|
||||
* @param operationPromise - The promise representing the database operation.
|
||||
* @param transformFn - A function that transforms the result.
|
||||
* @returns {Promise<T>} - A promise resolving to the transformed result.
|
||||
* @example
|
||||
* const user = await service.wrapResult(
|
||||
* service.findUnique({ where: { id: 'user_id' } }),
|
||||
* (result) => ({ ...result, fullName: `${result.firstName} ${result.lastName}` })
|
||||
* );
|
||||
*/
|
||||
async wrapResult<T>(
|
||||
operationPromise: Promise<T>,
|
||||
transformFn: (result: T) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await operationPromise;
|
||||
return await transformFn(result);
|
||||
} catch (error) {
|
||||
throw error; // Re-throw the error to maintain existing error handling
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,416 @@
|
|||
import { Prisma, PrismaClient } from '@nice/common';
|
||||
import { BaseService } from './base.service';
|
||||
import {
|
||||
DataArgs,
|
||||
DelegateArgs,
|
||||
DelegateFuncs,
|
||||
DelegateReturnTypes,
|
||||
UpdateOrderArgs,
|
||||
} from './base.type';
|
||||
|
||||
/**
|
||||
* BaseTreeService provides a generic CRUD interface for a tree prisma model.
|
||||
* It enables common data operations such as find, create, update, and delete.
|
||||
*
|
||||
* @template D - Type for the model delegate, defining available operations.
|
||||
* @template A - Arguments for the model delegate's operations.
|
||||
* @template R - Return types for the model delegate's operations.
|
||||
*/
|
||||
export class BaseTreeService<
|
||||
D extends DelegateFuncs,
|
||||
A extends DelegateArgs<D> = DelegateArgs<D>,
|
||||
R extends DelegateReturnTypes<D> = DelegateReturnTypes<D>,
|
||||
> extends BaseService<D, A, R> {
|
||||
constructor(
|
||||
protected prisma: PrismaClient,
|
||||
protected objectType: string,
|
||||
protected ancestryType: string = objectType + 'Ancestry',
|
||||
protected enableOrder: boolean = false,
|
||||
) {
|
||||
super(prisma, objectType, enableOrder);
|
||||
}
|
||||
async getNextOrder(
|
||||
transaction: any,
|
||||
parentId: string | null,
|
||||
parentOrder?: number,
|
||||
): Promise<number> {
|
||||
// 查找同层级最后一个节点的 order
|
||||
const lastOrder = await transaction[this.objectType].findFirst({
|
||||
where: {
|
||||
parentId: parentId ?? null,
|
||||
},
|
||||
select: { order: true },
|
||||
orderBy: { order: 'desc' },
|
||||
} as any);
|
||||
|
||||
// 如果有父节点
|
||||
if (parentId) {
|
||||
// 获取父节点的 order(如果未提供)
|
||||
const parentNodeOrder =
|
||||
parentOrder ??
|
||||
(
|
||||
await transaction[this.objectType].findUnique({
|
||||
where: { id: parentId },
|
||||
select: { order: true },
|
||||
})
|
||||
)?.order ??
|
||||
0;
|
||||
|
||||
// 如果存在最后一个同层级节点,确保新节点 order 大于最后一个节点
|
||||
// 否则,新节点 order 设置为父节点 order + 1
|
||||
return lastOrder
|
||||
? Math.max(
|
||||
lastOrder.order + this.ORDER_INTERVAL,
|
||||
parentNodeOrder + this.ORDER_INTERVAL,
|
||||
)
|
||||
: parentNodeOrder + this.ORDER_INTERVAL;
|
||||
}
|
||||
|
||||
// 对于根节点,直接使用最后一个节点的 order + 1
|
||||
return lastOrder?.order ? lastOrder?.order + this.ORDER_INTERVAL : 1;
|
||||
}
|
||||
|
||||
async create(args: A['create'], params?: any) {
|
||||
const anyArgs = args as any;
|
||||
// 如果传入了外部事务,直接使用该事务执行所有操作
|
||||
// 如果没有外部事务,则创建新事务
|
||||
const executor = async (transaction: any) => {
|
||||
if (this.enableOrder) {
|
||||
anyArgs.data.order = await this.getNextOrder(
|
||||
transaction,
|
||||
anyArgs?.data.parentId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
const result: any = await super.create(anyArgs, { tx: transaction });
|
||||
|
||||
if (anyArgs.data.parentId) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: anyArgs.data.parentId },
|
||||
data: { hasChildren: true },
|
||||
});
|
||||
}
|
||||
|
||||
const newAncestries = anyArgs.data.parentId
|
||||
? [
|
||||
...(
|
||||
await transaction[this.ancestryType].findMany({
|
||||
where: { descendantId: anyArgs.data.parentId },
|
||||
select: { ancestorId: true, relDepth: true },
|
||||
})
|
||||
).map(({ ancestorId, relDepth }) => ({
|
||||
ancestorId,
|
||||
descendantId: result.id,
|
||||
relDepth: relDepth + 1,
|
||||
})),
|
||||
{
|
||||
ancestorId: result.parentId,
|
||||
descendantId: result.id,
|
||||
relDepth: 1,
|
||||
},
|
||||
]
|
||||
: [{ ancestorId: null, descendantId: result.id, relDepth: 1 }];
|
||||
|
||||
await transaction[this.ancestryType].createMany({ data: newAncestries });
|
||||
|
||||
return result;
|
||||
};
|
||||
// 根据是否有外部事务决定执行方式
|
||||
if (params?.tx) {
|
||||
return executor(params.tx) as Promise<R['create']>;
|
||||
} else {
|
||||
return this.prisma.$transaction(executor) as Promise<R['create']>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新现有单位,并在parentId更改时管理DeptAncestry关系。
|
||||
* @param data - 用于更新现有单位的数据。
|
||||
* @returns 更新后的单位对象。
|
||||
*/
|
||||
async update(args: A['update'], params?: any) {
|
||||
const anyArgs = args as any;
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const current = await transaction[this.objectType].findUnique({
|
||||
where: { id: anyArgs.where.id },
|
||||
});
|
||||
|
||||
if (!current) throw new Error('object not found');
|
||||
|
||||
const result: any = await super.update(anyArgs, { tx: transaction });
|
||||
|
||||
if (anyArgs.data.parentId !== current.parentId) {
|
||||
await transaction[this.ancestryType].deleteMany({
|
||||
where: { descendantId: result.id },
|
||||
});
|
||||
// 更新原父级的 hasChildren 状态
|
||||
if (current.parentId) {
|
||||
const childrenCount = await transaction[this.objectType].count({
|
||||
where: { parentId: current.parentId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (childrenCount === 0) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: current.parentId },
|
||||
data: { hasChildren: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (anyArgs.data.parentId) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: anyArgs.data.parentId },
|
||||
data: { hasChildren: true },
|
||||
});
|
||||
const parentAncestries = await transaction[
|
||||
this.ancestryType
|
||||
].findMany({
|
||||
where: { descendantId: anyArgs.data.parentId },
|
||||
});
|
||||
|
||||
const newAncestries = parentAncestries.map(
|
||||
({ ancestorId, relDepth }) => ({
|
||||
ancestorId,
|
||||
descendantId: result.id,
|
||||
relDepth: relDepth + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
newAncestries.push({
|
||||
ancestorId: anyArgs.data.parentId,
|
||||
descendantId: result.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await transaction[this.ancestryType].createMany({
|
||||
data: newAncestries,
|
||||
});
|
||||
} else {
|
||||
await transaction[this.ancestryType].create({
|
||||
data: { ancestorId: null, descendantId: result.id, relDepth: 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as Promise<R['update']>;
|
||||
}
|
||||
/**
|
||||
* Soft deletes records by setting `isDeleted` to true for the given IDs.
|
||||
* @param ids - An array of IDs of the records to soft delete.
|
||||
* @param data - Additional data to update on soft delete. (Optional)
|
||||
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
|
||||
* @example
|
||||
* const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' });
|
||||
*/
|
||||
async softDeleteByIds(
|
||||
ids: string[],
|
||||
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
|
||||
): Promise<R['update'][]> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 首先找出所有需要软删除的记录的父级ID
|
||||
const parentIds = await tx[this.objectType].findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
parentId: { not: null },
|
||||
},
|
||||
select: { parentId: true },
|
||||
});
|
||||
|
||||
const uniqueParentIds = [...new Set(parentIds.map((p) => p.parentId))];
|
||||
|
||||
// 执行软删除
|
||||
const result = await super.softDeleteByIds(ids, data);
|
||||
|
||||
// 删除相关的祖先关系
|
||||
await tx[this.ancestryType].deleteMany({
|
||||
where: {
|
||||
OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }],
|
||||
},
|
||||
});
|
||||
// 更新父级的 hasChildren 状态
|
||||
if (uniqueParentIds.length > 0) {
|
||||
for (const parentId of uniqueParentIds) {
|
||||
const remainingChildrenCount = await tx[this.objectType].count({
|
||||
where: {
|
||||
parentId: parentId,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (remainingChildrenCount === 0) {
|
||||
await tx[this.objectType].update({
|
||||
where: { id: parentId },
|
||||
data: { hasChildren: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as Promise<R['update'][]>;
|
||||
}
|
||||
|
||||
getAncestors(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter((id) => id != null);
|
||||
const hasNull = ids.includes(null);
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDescendants(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter((id) => id != null);
|
||||
const hasNull = ids.includes(null);
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDescendantIds(
|
||||
ids: string | string[],
|
||||
includeOriginalIds: boolean = false,
|
||||
): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
const res = await this.getDescendants(idArray);
|
||||
const descendantSet = new Set(res?.map((item) => item.descendantId) || []);
|
||||
|
||||
if (includeOriginalIds) {
|
||||
idArray.forEach((id) => descendantSet.add(id));
|
||||
}
|
||||
|
||||
return Array.from(descendantSet).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
async getAncestorIds(
|
||||
ids: string | string[],
|
||||
includeOriginalIds: boolean = false,
|
||||
): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
const res = await this.getDescendants(idArray);
|
||||
const ancestorSet = new Set<string>();
|
||||
|
||||
// 按深度排序并添加祖先ID
|
||||
res
|
||||
?.sort((a, b) => b.relDepth - a.relDepth)
|
||||
?.forEach((item) => ancestorSet.add(item.ancestorId));
|
||||
|
||||
// 根据参数决定是否添加原始ID
|
||||
if (includeOriginalIds) {
|
||||
idArray.forEach((id) => ancestorSet.add(id));
|
||||
}
|
||||
|
||||
return Array.from(ancestorSet).filter(Boolean) as string[];
|
||||
}
|
||||
async updateOrder(args: UpdateOrderArgs) {
|
||||
const { id, overId } = args;
|
||||
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
// 查找当前节点和目标节点
|
||||
const currentObject = await transaction[this.objectType].findUnique({
|
||||
where: { id },
|
||||
select: { id: true, parentId: true, order: true },
|
||||
});
|
||||
|
||||
const targetObject = await transaction[this.objectType].findUnique({
|
||||
where: { id: overId },
|
||||
select: { id: true, parentId: true, order: true },
|
||||
});
|
||||
|
||||
// 验证节点
|
||||
if (!currentObject || !targetObject) {
|
||||
throw new Error('Invalid object or target object');
|
||||
}
|
||||
|
||||
// 查找父节点
|
||||
const parentObject = currentObject.parentId
|
||||
? await transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.parentId },
|
||||
select: { id: true, order: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
// 确保在同一父节点下移动
|
||||
if (currentObject.parentId !== targetObject.parentId) {
|
||||
throw new Error('Cannot move between different parent nodes');
|
||||
}
|
||||
|
||||
// 查找同层级的所有节点,按 order 排序
|
||||
const siblingNodes = await transaction[this.objectType].findMany({
|
||||
where: {
|
||||
parentId: targetObject.parentId,
|
||||
},
|
||||
select: { id: true, order: true },
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
// 找到目标节点和当前节点在兄弟节点中的索引
|
||||
const targetIndex = siblingNodes.findIndex(
|
||||
(node) => node.id === targetObject.id,
|
||||
);
|
||||
const currentIndex = siblingNodes.findIndex(
|
||||
(node) => node.id === currentObject.id,
|
||||
);
|
||||
|
||||
// 移除当前节点
|
||||
siblingNodes.splice(currentIndex, 1);
|
||||
|
||||
// 在目标位置插入当前节点
|
||||
const insertIndex =
|
||||
currentIndex > targetIndex ? targetIndex + 1 : targetIndex;
|
||||
siblingNodes.splice(insertIndex, 0, currentObject);
|
||||
|
||||
// 重新分配 order
|
||||
const newOrders = this.redistributeOrder(
|
||||
siblingNodes,
|
||||
parentObject?.order || 0,
|
||||
);
|
||||
|
||||
// 批量更新节点的 order
|
||||
const updatePromises = newOrders.map((nodeOrder, index) =>
|
||||
transaction[this.objectType].update({
|
||||
where: { id: siblingNodes[index].id },
|
||||
data: { order: nodeOrder },
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// 返回更新后的当前节点
|
||||
return transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 重新分配 order 的方法
|
||||
private redistributeOrder(
|
||||
nodes: Array<{ id: string; order: number }>,
|
||||
parentOrder: number,
|
||||
): number[] {
|
||||
const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点
|
||||
const newOrders: number[] = [];
|
||||
|
||||
nodes.forEach((_, index) => {
|
||||
// 使用等差数列分配 order,确保大于父节点
|
||||
const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL;
|
||||
newOrders.push(nodeOrder);
|
||||
});
|
||||
|
||||
return newOrders;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { db, Prisma, PrismaClient } from "@nice/common";
|
||||
|
||||
export type Operations =
|
||||
| 'aggregate'
|
||||
| 'count'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'delete'
|
||||
| 'deleteMany'
|
||||
| 'findFirst'
|
||||
| 'findMany'
|
||||
| 'findUnique'
|
||||
| 'update'
|
||||
| 'updateMany'
|
||||
| 'upsert';
|
||||
export type DelegateFuncs = { [K in Operations]: (args: any) => Promise<unknown> }
|
||||
export type DelegateArgs<T> = {
|
||||
[K in keyof T]: T[K] extends (args: infer A) => Promise<any> ? A : never;
|
||||
};
|
||||
|
||||
export type DelegateReturnTypes<T> = {
|
||||
[K in keyof T]: T[K] extends (args: any) => Promise<infer R> ? R : never;
|
||||
};
|
||||
|
||||
export type WhereArgs<T> = T extends { where?: infer W } ? W : never;
|
||||
export type SelectArgs<T> = T extends { select?: infer S } ? S : never;
|
||||
export type DataArgs<T> = T extends { data: infer D } ? D : never;
|
||||
export type IncludeArgs<T> = T extends { include: infer I } ? I : never;
|
||||
export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never;
|
||||
export type UpdateOrderArgs = {
|
||||
id: string
|
||||
overId: string
|
||||
}
|
||||
export interface FindManyWithCursorType<T extends DelegateFuncs> {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
where?: WhereArgs<DelegateArgs<T>['findUnique']>;
|
||||
select?: SelectArgs<DelegateArgs<T>['findUnique']>;
|
||||
orderBy?: OrderByArgs<DelegateArgs<T>['findMany']>
|
||||
}
|
||||
export type TransactionType = Omit<
|
||||
PrismaClient,
|
||||
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
||||
>;
|
|
@ -0,0 +1,198 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export const PrismaErrorCode = Object.freeze({
|
||||
P2000: 'P2000',
|
||||
P2001: 'P2001',
|
||||
P2002: 'P2002',
|
||||
P2003: 'P2003',
|
||||
P2006: 'P2006',
|
||||
P2007: 'P2007',
|
||||
P2008: 'P2008',
|
||||
P2009: 'P2009',
|
||||
P2010: 'P2010',
|
||||
P2011: 'P2011',
|
||||
P2012: 'P2012',
|
||||
P2014: 'P2014',
|
||||
P2015: 'P2015',
|
||||
P2016: 'P2016',
|
||||
P2017: 'P2017',
|
||||
P2018: 'P2018',
|
||||
P2019: 'P2019',
|
||||
P2021: 'P2021',
|
||||
P2023: 'P2023',
|
||||
P2025: 'P2025',
|
||||
P2031: 'P2031',
|
||||
P2033: 'P2033',
|
||||
P2034: 'P2034',
|
||||
P2037: 'P2037',
|
||||
P1000: 'P1000',
|
||||
P1001: 'P1001',
|
||||
P1002: 'P1002',
|
||||
P1015: 'P1015',
|
||||
P1017: 'P1017',
|
||||
});
|
||||
|
||||
export type PrismaErrorCode = keyof typeof PrismaErrorCode;
|
||||
|
||||
|
||||
interface PrismaErrorMeta {
|
||||
target?: string;
|
||||
model?: string;
|
||||
relationName?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export type operationT = 'create' | 'read' | 'update' | 'delete';
|
||||
|
||||
export type PrismaErrorHandler = (
|
||||
operation: operationT,
|
||||
meta?: PrismaErrorMeta,
|
||||
) => Error;
|
||||
|
||||
export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = {
|
||||
P2000: (_operation, meta) => new BadRequestException(
|
||||
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.`
|
||||
),
|
||||
|
||||
P2001: (operation, meta) => new NotFoundException(
|
||||
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`
|
||||
),
|
||||
|
||||
P2002: (operation, meta) => {
|
||||
const field = meta?.target || 'unique field';
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return new ConflictException(
|
||||
`A record with the same ${field} already exists. Please use a different value.`
|
||||
);
|
||||
case 'update':
|
||||
return new ConflictException(
|
||||
`The new value for ${field} conflicts with an existing record.`
|
||||
);
|
||||
default:
|
||||
return new ConflictException(
|
||||
`Unique constraint violation on ${field}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
P2003: (operation) => new BadRequestException(
|
||||
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`
|
||||
),
|
||||
|
||||
P2006: (_operation, meta) => new BadRequestException(
|
||||
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`
|
||||
),
|
||||
|
||||
P2007: (operation) => new InternalServerErrorException(
|
||||
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`
|
||||
),
|
||||
|
||||
P2008: (operation) => new InternalServerErrorException(
|
||||
`Failed to query the database during ${operation}. Please try again later.`
|
||||
),
|
||||
|
||||
P2009: (operation) => new InternalServerErrorException(
|
||||
`Invalid data fetched during ${operation}. Check query structure.`
|
||||
),
|
||||
|
||||
P2010: () => new InternalServerErrorException(
|
||||
`Invalid raw query. Ensure your query is correct and try again.`
|
||||
),
|
||||
|
||||
P2011: (_operation, meta) => new BadRequestException(
|
||||
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`
|
||||
),
|
||||
|
||||
P2012: (operation, meta) => new BadRequestException(
|
||||
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`
|
||||
),
|
||||
|
||||
P2014: (operation) => {
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return new BadRequestException(
|
||||
`Cannot create record because the referenced data does not exist. Ensure related data exists.`
|
||||
);
|
||||
case 'delete':
|
||||
return new BadRequestException(
|
||||
`Unable to delete record because it is linked to other data. Update or delete dependent records first.`
|
||||
);
|
||||
default:
|
||||
return new BadRequestException(`Foreign key constraint error.`);
|
||||
}
|
||||
},
|
||||
|
||||
P2015: () => new InternalServerErrorException(
|
||||
`A record with the required ID was expected but not found. Please retry.`
|
||||
),
|
||||
|
||||
P2016: (operation) => new InternalServerErrorException(
|
||||
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`
|
||||
),
|
||||
|
||||
P2017: (operation) => new InternalServerErrorException(
|
||||
`Connected records were not found for ${operation}. Check related data.`
|
||||
),
|
||||
|
||||
P2018: () => new InternalServerErrorException(
|
||||
`The required connection could not be established. Please check relationships.`
|
||||
),
|
||||
|
||||
P2019: (_operation, meta) => new InternalServerErrorException(
|
||||
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`
|
||||
),
|
||||
|
||||
P2021: (_operation, meta) => new InternalServerErrorException(
|
||||
`The ${meta?.model || 'model'} was not found in the database.`
|
||||
),
|
||||
|
||||
P2025: (operation, meta) => new NotFoundException(
|
||||
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`
|
||||
),
|
||||
|
||||
P2031: () => new InternalServerErrorException(
|
||||
`Invalid Prisma Client initialization error. Please check configuration.`
|
||||
),
|
||||
|
||||
P2033: (operation) => new InternalServerErrorException(
|
||||
`Insufficient database write permissions for ${operation}.`
|
||||
),
|
||||
|
||||
P2034: (operation) => new InternalServerErrorException(
|
||||
`Database read-only transaction failed during ${operation}.`
|
||||
),
|
||||
|
||||
P2037: (operation) => new InternalServerErrorException(
|
||||
`Unsupported combinations of input types for ${operation}. Please correct the query or input.`
|
||||
),
|
||||
|
||||
P1000: () => new InternalServerErrorException(
|
||||
`Database authentication failed. Verify your credentials and try again.`
|
||||
),
|
||||
|
||||
P1001: () => new InternalServerErrorException(
|
||||
`The database server could not be reached. Please check its availability.`
|
||||
),
|
||||
|
||||
P1002: () => new InternalServerErrorException(
|
||||
`Connection to the database timed out. Verify network connectivity and server availability.`
|
||||
),
|
||||
|
||||
P1015: (operation) => new InternalServerErrorException(
|
||||
`Migration failed. Unable to complete ${operation}. Check migration history or database state.`
|
||||
),
|
||||
|
||||
P1017: () => new InternalServerErrorException(
|
||||
`Database connection failed. Ensure the database is online and credentials are correct.`
|
||||
),
|
||||
P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { UserProfile, RowModelRequest, RowRequestSchema } from "@nice/common";
|
||||
import { RowModelService } from "./row-model.service";
|
||||
import { isFieldCondition, LogicalCondition, SQLBuilder } from "./sql-builder";
|
||||
import EventBus from "@server/utils/event-bus";
|
||||
import supejson from "superjson-cjs"
|
||||
import { deleteByPattern } from "@server/utils/redis/utils";
|
||||
import { redis } from "@server/utils/redis/redis.service";
|
||||
import { z } from "zod";
|
||||
export class RowCacheService extends RowModelService {
|
||||
constructor(tableName: string, private enableCache: boolean = true) {
|
||||
super(tableName)
|
||||
if (this.enableCache) {
|
||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
||||
if (type === tableName) {
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
for (const item of dataArray) {
|
||||
try {
|
||||
if (item.id) {
|
||||
this.invalidateRowCacheById(item.id)
|
||||
}
|
||||
if (item.parentId) {
|
||||
this.invalidateRowCacheById(item.parentId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error deleting cache for type ${tableName}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
protected getRowCacheKey(id: string) {
|
||||
return `row-data-${id}`;
|
||||
}
|
||||
private async invalidateRowCacheById(id: string) {
|
||||
if (!this.enableCache) return;
|
||||
const pattern = this.getRowCacheKey(id);
|
||||
await deleteByPattern(pattern);
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return []
|
||||
}
|
||||
protected async getRowRelation(args: { data: any, staff?: UserProfile }) {
|
||||
return args.data;
|
||||
}
|
||||
protected async setResPermissions(
|
||||
data: any,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
return data
|
||||
}
|
||||
protected async getRowDto(
|
||||
data: any,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
// 如果没有id,直接返回原数据
|
||||
if (!data?.id) return data;
|
||||
// 如果未启用缓存,直接处理并返回数据
|
||||
if (!this.enableCache) {
|
||||
return this.processDataWithPermissions(data, staff);
|
||||
}
|
||||
const key = this.getRowCacheKey(data.id);
|
||||
try {
|
||||
// 尝试从缓存获取数据
|
||||
const cachedData = await this.getCachedData(key, staff);
|
||||
// 如果缓存命中,直接返回
|
||||
if (cachedData) return cachedData;
|
||||
// 处理数据并缓存
|
||||
const processedData = await this.processDataWithPermissions(data, staff);
|
||||
await redis.set(key, supejson.stringify(processedData));
|
||||
return processedData;
|
||||
} catch (err) {
|
||||
this.logger.error('Error in getRowDto:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedData(
|
||||
key: string,
|
||||
staff?: UserProfile
|
||||
): Promise<any | null> {
|
||||
const cachedDataStr = await redis.get(key);
|
||||
if (!cachedDataStr) return null;
|
||||
const cachedData = supejson.parse(cachedDataStr) as any;
|
||||
if (!cachedData?.id) return null;
|
||||
return staff
|
||||
? this.setResPermissions(cachedData, staff)
|
||||
: cachedData;
|
||||
}
|
||||
|
||||
private async processDataWithPermissions(
|
||||
data: any,
|
||||
staff?: UserProfile
|
||||
): Promise<any> {
|
||||
// 处理权限
|
||||
const permData = staff
|
||||
? await this.setResPermissions(data, staff)
|
||||
: data;
|
||||
// 获取关联数据
|
||||
return this.getRowRelation({ data: permData, staff });
|
||||
}
|
||||
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof RowRequestSchema>,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
if (isFieldCondition(condition)) return {};
|
||||
const baseCondition: LogicalCondition[] = [
|
||||
{
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
condition.AND = [...baseCondition, ...condition.AND];
|
||||
return condition;
|
||||
}
|
||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`${this.tableName}.id AS id`,
|
||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||
];
|
||||
}
|
||||
protected createGroupingRowSelect(
|
||||
request: RowModelRequest,
|
||||
wrapperSql: boolean,
|
||||
): string[] {
|
||||
const colsToSelect = super.createGroupingRowSelect(request, wrapperSql);
|
||||
return colsToSelect.concat([
|
||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||
]);
|
||||
}
|
||||
protected async getRowsSqlWrapper(
|
||||
sql: string,
|
||||
request?: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
): Promise<string> {
|
||||
const groupingSql = SQLBuilder.join([
|
||||
SQLBuilder.select([
|
||||
...this.createGroupingRowSelect(request, true),
|
||||
`${this.tableName}.id AS id`,
|
||||
]),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||
]);
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
if (this.isDoingGroup(request)) {
|
||||
const rowGroupCol = rowGroupCols[groupKeys.length];
|
||||
const groupByField = rowGroupCol?.field?.replace('.', '_');
|
||||
return SQLBuilder.join([
|
||||
SQLBuilder.select([
|
||||
groupByField,
|
||||
...super.createAggSqlForWrapper(request),
|
||||
'COUNT(id) AS child_count',
|
||||
]),
|
||||
SQLBuilder.from(`(${groupingSql})`),
|
||||
SQLBuilder.where({
|
||||
field: 'row_num',
|
||||
value: '1',
|
||||
op: 'equals',
|
||||
}),
|
||||
SQLBuilder.groupBy([groupByField]),
|
||||
SQLBuilder.orderBy(
|
||||
this.getOrderByColumns(request).map((item) => item.replace('.', '_')),
|
||||
),
|
||||
this.getLimitSql(request),
|
||||
]);
|
||||
} else
|
||||
return SQLBuilder.join([
|
||||
SQLBuilder.select(['*']),
|
||||
SQLBuilder.from(`(${sql})`),
|
||||
SQLBuilder.where({
|
||||
field: 'row_num',
|
||||
value: '1',
|
||||
op: 'equals',
|
||||
}),
|
||||
this.getLimitSql(request),
|
||||
]);
|
||||
// return super.getRowsSqlWrapper(sql, request)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,307 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import { UserProfile, db, RowModelRequest } from '@nice/common';
|
||||
import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder';
|
||||
export interface GetRowOptions {
|
||||
id?: string;
|
||||
ids?: string[];
|
||||
extraCondition?: LogicalCondition;
|
||||
staff?: UserProfile;
|
||||
}
|
||||
export abstract class RowModelService {
|
||||
private keywords: Set<string> = new Set([
|
||||
'SELECT',
|
||||
'FROM',
|
||||
'WHERE',
|
||||
'ORDER',
|
||||
'BY',
|
||||
'GROUP',
|
||||
'JOIN',
|
||||
'AND',
|
||||
'OR',
|
||||
// 添加更多需要引号的关键词
|
||||
]);
|
||||
protected logger = new Logger(this.tableName);
|
||||
protected constructor(protected tableName: string) { }
|
||||
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||
return row;
|
||||
}
|
||||
protected async getRowsSqlWrapper(
|
||||
sql: string,
|
||||
request?: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
if (request) return SQLBuilder.join([sql, this.getLimitSql(request)]);
|
||||
return sql;
|
||||
}
|
||||
protected getLimitSql(request: RowModelRequest) {
|
||||
return SQLBuilder.limit(
|
||||
request.endRow - request.startRow,
|
||||
request.startRow,
|
||||
);
|
||||
}
|
||||
abstract createJoinSql(request?: RowModelRequest): string[];
|
||||
async getRows(request: RowModelRequest, staff?: UserProfile) {
|
||||
try {
|
||||
let SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.getRowSelectCols(request)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||
SQLBuilder.groupBy(this.getGroupByColumns(request)),
|
||||
SQLBuilder.orderBy(this.getOrderByColumns(request)),
|
||||
]);
|
||||
SQL = await this.getRowsSqlWrapper(SQL, request, staff);
|
||||
|
||||
// this.logger.debug('getrows', SQL);
|
||||
|
||||
const results: any[] = (await db?.$queryRawUnsafe(SQL)) || [];
|
||||
|
||||
const rowDataDto = await Promise.all(
|
||||
results.map((row) => this.getRowDto(row, staff)),
|
||||
);
|
||||
return {
|
||||
rowCount: this.getRowCount(request, rowDataDto) || 0,
|
||||
rowData: rowDataDto,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error executing getRows:', error);
|
||||
}
|
||||
}
|
||||
getRowCount(request: RowModelRequest, results: any[]) {
|
||||
if (results === null || results === undefined || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const currentLastRow = request.startRow + results.length;
|
||||
return currentLastRow <= request.endRow ? currentLastRow : -1;
|
||||
}
|
||||
|
||||
async getRowById(options: GetRowOptions): Promise<any> {
|
||||
const {
|
||||
id,
|
||||
extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
staff,
|
||||
} = options;
|
||||
return this.getSingleRow(
|
||||
{ AND: [this.createGetByIdFilter(id!), extraCondition] },
|
||||
staff,
|
||||
);
|
||||
}
|
||||
|
||||
async getRowByIds(options: GetRowOptions): Promise<any[]> {
|
||||
const {
|
||||
ids,
|
||||
extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
staff,
|
||||
} = options;
|
||||
return this.getMultipleRows(
|
||||
{ AND: [this.createGetByIdsFilter(ids!), extraCondition] },
|
||||
staff,
|
||||
);
|
||||
}
|
||||
|
||||
protected createGetRowsFilters(
|
||||
request: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
): LogicalCondition {
|
||||
let groupConditions: LogicalCondition[] = [];
|
||||
if (this.isDoingTreeGroup(request)) {
|
||||
groupConditions = [
|
||||
{
|
||||
field: 'parent_id',
|
||||
op: 'equals' as OperatorType,
|
||||
value: request.groupKeys[request.groupKeys.length - 1],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
groupConditions = request?.groupKeys?.map((key, index) => ({
|
||||
field: request.rowGroupCols[index].field,
|
||||
op: 'equals' as OperatorType,
|
||||
value: key,
|
||||
}));
|
||||
}
|
||||
|
||||
const condition: LogicalCondition = {
|
||||
AND: [
|
||||
...groupConditions,
|
||||
...this.buildFilterConditions(request.filterModel),
|
||||
],
|
||||
};
|
||||
|
||||
return condition;
|
||||
}
|
||||
private buildFilterConditions(filterModel: any): LogicalCondition[] {
|
||||
return filterModel
|
||||
? Object.entries(filterModel)?.map(([key, item]) =>
|
||||
SQLBuilder.createFilterSql(
|
||||
key === 'ag-Grid-AutoColumn' ? 'name' : key,
|
||||
item,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
getRowSelectCols(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? this.createGroupingRowSelect(request)
|
||||
: this.createUnGroupingRowSelect(request);
|
||||
}
|
||||
protected createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
protected createAggSqlForWrapper(request: RowModelRequest) {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
return valueCols.map(
|
||||
(valueCol) =>
|
||||
`${valueCol.aggFunc}(${valueCol.field.replace('.', '_')}) AS ${valueCol.field.split('.').join('_')}`,
|
||||
);
|
||||
}
|
||||
protected createGroupingRowSelect(
|
||||
request: RowModelRequest,
|
||||
wrapperSql: boolean = false,
|
||||
): string[] {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
const colsToSelect: string[] = [];
|
||||
|
||||
const rowGroupCol = rowGroupCols[groupKeys!.length];
|
||||
if (rowGroupCol) {
|
||||
colsToSelect.push(
|
||||
`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`,
|
||||
);
|
||||
}
|
||||
colsToSelect.push(
|
||||
...valueCols.map(
|
||||
(valueCol) =>
|
||||
`${wrapperSql ? '' : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
|
||||
),
|
||||
);
|
||||
|
||||
return colsToSelect;
|
||||
}
|
||||
|
||||
getGroupByColumns(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? [request.rowGroupCols[request.groupKeys!.length]?.field]
|
||||
: [];
|
||||
}
|
||||
|
||||
getOrderByColumns(request: RowModelRequest): string[] {
|
||||
const { sortModel, rowGroupCols, groupKeys } = request;
|
||||
const grouping = this.isDoingGroup(request);
|
||||
const sortParts: string[] = [];
|
||||
|
||||
if (sortModel) {
|
||||
const groupColIds = rowGroupCols
|
||||
.map((groupCol) => groupCol.id)
|
||||
.slice(0, groupKeys.length + 1);
|
||||
sortModel.forEach((item) => {
|
||||
if (
|
||||
!grouping ||
|
||||
(groupColIds.indexOf(item.colId) >= 0 &&
|
||||
rowGroupCols[groupKeys.length].field === item.colId)
|
||||
) {
|
||||
const colId = this.keywords.has(item.colId.toUpperCase())
|
||||
? `"${item.colId}"`
|
||||
: item.colId;
|
||||
sortParts.push(`${colId} ${item.sort}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sortParts;
|
||||
}
|
||||
isDoingGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length > requset.groupKeys.length;
|
||||
}
|
||||
isDoingTreeGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0;
|
||||
}
|
||||
private async getSingleRow(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
const results = await this.getRowsWithFilters(condition, staff);
|
||||
return results[0];
|
||||
}
|
||||
private async getMultipleRows(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any[]> {
|
||||
return this.getRowsWithFilters(condition, staff);
|
||||
}
|
||||
|
||||
private async getRowsWithFilters(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.createUnGroupingRowSelect()),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql()),
|
||||
SQLBuilder.where(condition),
|
||||
]);
|
||||
|
||||
// this.logger.debug(SQL)
|
||||
const results: any[] = await db.$queryRawUnsafe(SQL);
|
||||
|
||||
const rowDataDto = await Promise.all(
|
||||
results.map((item) => this.getRowDto(item, staff)),
|
||||
);
|
||||
|
||||
// rowDataDto = getUniqueItems(rowDataDto, "id")
|
||||
return rowDataDto;
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAggValues(request: RowModelRequest) {
|
||||
try {
|
||||
const SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.buildAggSelect(request.valueCols)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request)),
|
||||
SQLBuilder.groupBy(this.buildAggGroupBy()),
|
||||
]);
|
||||
const result: any[] = await db.$queryRawUnsafe(SQL);
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
protected buildAggGroupBy(): string[] {
|
||||
return [];
|
||||
}
|
||||
protected buildAggSelect(valueCols: any[]): string[] {
|
||||
return valueCols.map(
|
||||
(valueCol) =>
|
||||
`${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
|
||||
);
|
||||
}
|
||||
|
||||
private createGetByIdFilter(id: string): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: id,
|
||||
op: 'equals',
|
||||
};
|
||||
}
|
||||
private createGetByIdsFilter(ids: string[]): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: ids,
|
||||
op: 'in',
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,138 @@
|
|||
export interface FieldCondition {
|
||||
field: string;
|
||||
op: OperatorType
|
||||
type?: "text" | "number" | "date";
|
||||
value?: any;
|
||||
valueTo?: any;
|
||||
};
|
||||
export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in';
|
||||
export type LogicalCondition = FieldCondition | {
|
||||
AND?: LogicalCondition[];
|
||||
OR?: LogicalCondition[];
|
||||
};
|
||||
|
||||
export function isFieldCondition(condition: LogicalCondition): condition is FieldCondition {
|
||||
return (condition as FieldCondition).field !== undefined;
|
||||
}
|
||||
function buildCondition(condition: FieldCondition): string {
|
||||
const { field, op, value, type = "text", valueTo } = condition;
|
||||
switch (op) {
|
||||
case 'equals':
|
||||
return `${field} = '${value}'`;
|
||||
case 'notEqual':
|
||||
return `${field} != '${value}'`;
|
||||
case 'contains':
|
||||
return `${field} LIKE '%${value}%'`;
|
||||
case 'startsWith':
|
||||
return `${field} LIKE '${value}%'`;
|
||||
case 'endsWith':
|
||||
return `${field} LIKE '%${value}'`;
|
||||
case 'blank':
|
||||
if (type !== "date")
|
||||
return `(${field} IS NULL OR ${field} = '')`;
|
||||
else
|
||||
return `${field} IS NULL`;
|
||||
case 'notBlank':
|
||||
if (type !== 'date')
|
||||
return `${field} IS NOT NULL AND ${field} != ''`;
|
||||
else
|
||||
return `${field} IS NOT NULL`;
|
||||
case 'greaterThan':
|
||||
return `${field} > '${value}'`;
|
||||
case 'lessThanOrEqual':
|
||||
return `${field} <= '${value}'`;
|
||||
case 'lessThan':
|
||||
return `${field} < '${value}'`;
|
||||
case 'greaterThan':
|
||||
return `${field} > '${value}'`;
|
||||
case 'inRange':
|
||||
return `${field} >= '${value}' AND ${field} <= '${valueTo}'`;
|
||||
case 'in':
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
// Return a condition that is always false if value is empty or an empty array
|
||||
return '1 = 0';
|
||||
}
|
||||
return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`;
|
||||
default:
|
||||
return 'true'; // Default return for unmatched conditions
|
||||
}
|
||||
}
|
||||
function buildLogicalCondition(logicalCondition: LogicalCondition): string {
|
||||
if (isFieldCondition(logicalCondition)) {
|
||||
return buildCondition(logicalCondition);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (logicalCondition.AND && logicalCondition.AND.length > 0) {
|
||||
const andParts = logicalCondition.AND
|
||||
.map(c => buildLogicalCondition(c))
|
||||
.filter(part => part !== ''); // Filter out empty conditions
|
||||
if (andParts.length > 0) {
|
||||
parts.push(`(${andParts.join(' AND ')})`);
|
||||
}
|
||||
}
|
||||
// Process OR conditions
|
||||
if (logicalCondition.OR && logicalCondition.OR.length > 0) {
|
||||
const orParts = logicalCondition.OR
|
||||
.map(c => buildLogicalCondition(c))
|
||||
.filter(part => part !== ''); // Filter out empty conditions
|
||||
if (orParts.length > 0) {
|
||||
parts.push(`(${orParts.join(' OR ')})`);
|
||||
}
|
||||
}
|
||||
// Join AND and OR parts with an 'AND' if both are present
|
||||
return parts.length > 1 ? parts.join(' AND ') : parts[0] || '';
|
||||
}
|
||||
|
||||
export class SQLBuilder {
|
||||
static select(fields: string[], distinctField?: string): string {
|
||||
const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : "";
|
||||
return `SELECT ${distinctClause}${fields.join(", ")}`;
|
||||
}
|
||||
static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string {
|
||||
if (!orderBy) {
|
||||
throw new Error("orderBy 参数不能为空");
|
||||
}
|
||||
|
||||
let partitionClause = '';
|
||||
if (partitionBy) {
|
||||
partitionClause = `PARTITION BY ${partitionBy} `;
|
||||
}
|
||||
|
||||
return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`;
|
||||
}
|
||||
static from(tableName: string): string {
|
||||
return `FROM ${tableName}`;
|
||||
}
|
||||
|
||||
static where(conditions: LogicalCondition): string {
|
||||
const whereClause = buildLogicalCondition(conditions);
|
||||
return whereClause ? `WHERE ${whereClause}` : "";
|
||||
}
|
||||
|
||||
static groupBy(columns: string[]): string {
|
||||
return columns.length ? `GROUP BY ${columns.join(", ")}` : "";
|
||||
}
|
||||
|
||||
static orderBy(columns: string[]): string {
|
||||
return columns.length ? `ORDER BY ${columns.join(", ")}` : "";
|
||||
}
|
||||
|
||||
static limit(pageSize: number, offset: number = 0): string {
|
||||
return `LIMIT ${pageSize + 1} OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
static join(clauses: string[]): string {
|
||||
return clauses.filter(Boolean).join(' ');
|
||||
}
|
||||
static createFilterSql(key: string, item: any): LogicalCondition {
|
||||
const conditionFuncs: Record<string, (item: { values?: any[], dateFrom?: string, dateTo?: string, filter: any, type: OperatorType, filterType: OperatorType }) => LogicalCondition> = {
|
||||
text: (item) => ({ value: item.filter, op: item.type, field: key }),
|
||||
number: (item) => ({ value: item.filter, op: item.type, field: key }),
|
||||
date: (item) => ({ value: item.dateFrom, valueTo: item.dateTo, op: item.type, field: key }),
|
||||
set: (item) => ({ value: item.values, op: "in", field: key })
|
||||
}
|
||||
return conditionFuncs[item.filterType](item)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
SELECT *
|
||||
FROM (
|
||||
SELECT staff.id AS id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY staff.id
|
||||
ORDER BY staff.id
|
||||
) AS row_num,
|
||||
staff.id AS id,
|
||||
staff.username AS username,
|
||||
staff.showname AS showname,
|
||||
staff.avatar AS avatar,
|
||||
staff.officer_id AS officer_id,
|
||||
staff.phone_number AS phone_number,
|
||||
staff.order AS order,
|
||||
staff.enabled AS enabled,
|
||||
dept.name AS dept_name,
|
||||
domain.name AS domain_name
|
||||
FROM staff
|
||||
LEFT JOIN department dept ON staff.dept_id = dept.id
|
||||
LEFT JOIN department domain ON staff.domain_id = domain.id
|
||||
WHERE (
|
||||
staff.deleted_at IS NULL
|
||||
AND enabled = 'Thu Dec 26 2024 11:55:47 GMT+0800 (中国标准时间)'
|
||||
AND staff.domain_id = '784c7583-c7f3-4179-873d-f8195ccf2acf'
|
||||
AND staff.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY "order" asc
|
||||
)
|
||||
WHERE row_num = '1'
|
||||
LIMIT 31 OFFSET 0
|
|
@ -0,0 +1,87 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { DepartmentService } from './department.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('dept')
|
||||
export class DepartmentController {
|
||||
constructor(private readonly deptService: DepartmentService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('get-detail')
|
||||
async getDepartmentDetails(@Query('dept-id') deptId: string) {
|
||||
try {
|
||||
const result = await this.deptService.findById(deptId);
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('get-all-child-dept-ids')
|
||||
async getAllChildDeptIds(@Query('dept-id') deptId: string) {
|
||||
try {
|
||||
const result = await this.deptService.getDescendantIds([deptId]);
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('get-all-parent-dept-ids')
|
||||
async getAllParentDeptIds(@Query('dept-id') deptId: string) {
|
||||
try {
|
||||
const result = await this.deptService.getAncestorIds([deptId]);
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-by-name-in-dom')
|
||||
async findInDomain(
|
||||
@Query('domain-id') domainId?: string,
|
||||
@Query('name') name?: string,
|
||||
) {
|
||||
try {
|
||||
const result = await this.deptService.findInDomain(domainId, name);
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DepartmentService } from './department.service';
|
||||
import { DepartmentRouter } from './department.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentController } from './department.controller';
|
||||
import { DepartmentRowService } from './department.row.service';
|
||||
|
||||
@Module({
|
||||
providers: [DepartmentService, DepartmentRouter, DepartmentRowService, TrpcService],
|
||||
exports: [DepartmentService, DepartmentRouter],
|
||||
controllers: [DepartmentController],
|
||||
})
|
||||
export class DepartmentModule { }
|
|
@ -0,0 +1,71 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from './department.service'; // assuming it's in the same directory
|
||||
import { DepartmentMethodSchema, Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { DepartmentRowService } from './department.row.service';
|
||||
|
||||
const DepartmentCreateArgsSchema: ZodType<Prisma.DepartmentCreateArgs> = z.any()
|
||||
const DepartmentUpdateArgsSchema: ZodType<Prisma.DepartmentUpdateArgs> = z.any()
|
||||
const DepartmentFindFirstArgsSchema: ZodType<Prisma.DepartmentFindFirstArgs> = z.any()
|
||||
const DepartmentFindManyArgsSchema: ZodType<Prisma.DepartmentFindManyArgs> = z.any()
|
||||
@Injectable()
|
||||
export class DepartmentRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly departmentService: DepartmentService, // 注入 DepartmentService
|
||||
private readonly departmentRowService: DepartmentRowService
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
// 创建部门
|
||||
create: this.trpc.protectProcedure
|
||||
.input(DepartmentCreateArgsSchema) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.create(input);
|
||||
}),
|
||||
// 更新部门
|
||||
update: this.trpc.protectProcedure
|
||||
.input(DepartmentUpdateArgsSchema) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.update(input);
|
||||
}),
|
||||
// 根据 ID 列表软删除部门
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
// 更新部门顺序
|
||||
updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => {
|
||||
return this.departmentService.updateOrder(input)
|
||||
}),
|
||||
// 查询多个部门
|
||||
findMany: this.trpc.procedure
|
||||
.input(DepartmentFindManyArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema
|
||||
.query(async ({ input }) => {
|
||||
return await this.departmentService.findMany(input);
|
||||
}),
|
||||
// 查询第一个部门
|
||||
findFirst: this.trpc.procedure
|
||||
.input(DepartmentFindFirstArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema
|
||||
.query(async ({ input }) => {
|
||||
return await this.departmentService.findFirst(input);
|
||||
}),
|
||||
// 获取子部门的简单树结构
|
||||
getChildSimpleTree: this.trpc.procedure
|
||||
.input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => {
|
||||
return await this.departmentService.getChildSimpleTree(input)
|
||||
}),
|
||||
// 获取父部门的简单树结构
|
||||
getParentSimpleTree: this.trpc.procedure
|
||||
.input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => {
|
||||
return await this.departmentService.getParentSimpleTree(input)
|
||||
}),
|
||||
// 获取部门行数据
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(DepartmentMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.departmentRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 部门行服务类,继承自 RowCacheService,用于处理部门相关的数据行操作。
|
||||
* 该类提供了生成 SQL 查询字段、构建查询过滤器等功能。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
DepartmentMethodSchema,
|
||||
ObjectType,
|
||||
UserProfile,
|
||||
} from '@nice/common';
|
||||
import { date, z } from 'zod';
|
||||
import { RowCacheService } from '../base/row-cache.service';
|
||||
import { isFieldCondition } from '../base/sql-builder';
|
||||
|
||||
@Injectable()
|
||||
export class DepartmentRowService extends RowCacheService {
|
||||
/**
|
||||
* 构造函数,初始化 DepartmentRowService。
|
||||
* 调用父类构造函数,传入部门对象类型并禁用缓存。
|
||||
*/
|
||||
constructor() {
|
||||
super(ObjectType.DEPARTMENT, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成未分组行的 SQL 查询字段列表,包括部门特定的字段。
|
||||
* @param requset - 请求对象,符合 DepartmentMethodSchema.getRows 的 schema。
|
||||
* @returns 返回包含 SQL 查询字段的数组。
|
||||
*/
|
||||
createUnGroupingRowSelect(
|
||||
requset: z.infer<typeof DepartmentMethodSchema.getRows>,
|
||||
): string[] {
|
||||
// 调用父类方法生成基础查询字段,并拼接部门特定的字段
|
||||
const result = super.createUnGroupingRowSelect(requset).concat([
|
||||
`${this.tableName}.name AS name`, // 部门名称
|
||||
`${this.tableName}.is_domain AS is_domain`, // 是否为域
|
||||
`${this.tableName}.order AS order`, // 排序
|
||||
`${this.tableName}.has_children AS has_children`, // 是否有子部门
|
||||
`${this.tableName}.parent_id AS parent_id`, // 父部门 ID
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求和用户信息构建 getRows 查询的过滤器。
|
||||
* @param request - 请求对象,符合 DepartmentMethodSchema.getRows 的 schema。
|
||||
* @param staff - 发起请求的用户信息。
|
||||
* @returns 返回用于过滤行的条件对象。
|
||||
*/
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof DepartmentMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
// 调用父类方法生成基础过滤条件
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
const { parentId, includeDeleted = false } = request;
|
||||
|
||||
// 如果条件已经是字段条件,则跳过后续处理
|
||||
if (isFieldCondition(condition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果请求中没有分组键,则添加父部门 ID 过滤条件
|
||||
if (request.groupKeys.length === 0) {
|
||||
if (parentId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
value: parentId,
|
||||
op: 'equals', // 等于操作符
|
||||
});
|
||||
} else if (parentId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
op: 'blank', // 空白操作符
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 includeDeleted 为 false,则排除已删除的行
|
||||
if (!includeDeleted) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
type: 'date',
|
||||
op: 'blank', // 空白操作符
|
||||
});
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,338 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
DepartmentMethodSchema,
|
||||
DeptAncestry,
|
||||
getUniqueItems,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
} from '@nice/common';
|
||||
import { BaseTreeService } from '../base/base.tree.service';
|
||||
import { z } from 'zod';
|
||||
import { mapToDeptSimpleTree, getStaffsByDeptIds } from './utils';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
@Injectable()
|
||||
export class DepartmentService extends BaseTreeService<Prisma.DepartmentDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.DEPARTMENT, 'deptAncestry', true);
|
||||
}
|
||||
async getDescendantIdsInDomain(
|
||||
ancestorId: string,
|
||||
includeAncestor = true,
|
||||
): Promise<string[]> {
|
||||
// 如果没有提供部门ID,返回空数组
|
||||
if (!ancestorId) return [];
|
||||
// 获取祖先部门信息
|
||||
const ancestorDepartment = await db.department.findUnique({
|
||||
where: { id: ancestorId },
|
||||
});
|
||||
|
||||
// 如果未找到部门,返回空数组
|
||||
if (!ancestorDepartment) return [];
|
||||
|
||||
// 查询同域下以指定部门为祖先的部门血缘关系
|
||||
const departmentAncestries = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: ancestorId,
|
||||
descendant: {
|
||||
domainId: ancestorDepartment.domainId,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 提取子部门ID列表
|
||||
const descendantDepartmentIds = departmentAncestries.map(
|
||||
(ancestry) => ancestry.descendantId,
|
||||
);
|
||||
// 根据参数决定是否包含祖先部门ID
|
||||
if (includeAncestor && ancestorId) {
|
||||
descendantDepartmentIds.push(ancestorId);
|
||||
}
|
||||
return descendantDepartmentIds;
|
||||
}
|
||||
async getDescendantDomainIds(
|
||||
ancestorDomainId: string,
|
||||
includeAncestorDomain = true,
|
||||
): Promise<string[]> {
|
||||
if (!ancestorDomainId) return [];
|
||||
// 查询所有以指定域ID为祖先的域的血缘关系
|
||||
const domainAncestries = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: ancestorDomainId,
|
||||
descendant: {
|
||||
isDomain: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 提取子域的ID列表
|
||||
const descendantDomainIds = domainAncestries.map(
|
||||
(ancestry) => ancestry.descendantId,
|
||||
);
|
||||
// 根据参数决定是否包含祖先域ID
|
||||
if (includeAncestorDomain && ancestorDomainId) {
|
||||
descendantDomainIds.push(ancestorDomainId);
|
||||
}
|
||||
|
||||
return descendantDomainIds;
|
||||
}
|
||||
/**
|
||||
* 获取指定DOM下的对应name的单位
|
||||
* @param domainId
|
||||
* @param name
|
||||
* @returns
|
||||
*/
|
||||
async findInDomain(domainId: string, name: string) {
|
||||
const subDepts = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: domainId,
|
||||
},
|
||||
include: {
|
||||
descendant: true,
|
||||
},
|
||||
});
|
||||
const dept = subDepts.find((item) => item.descendant.name === name);
|
||||
|
||||
return dept.descendant;
|
||||
}
|
||||
|
||||
private async setDomainId(parentId: string) {
|
||||
const parent = await this.findUnique({ where: { id: parentId } });
|
||||
return parent.isDomain ? parentId : parent.domainId;
|
||||
}
|
||||
|
||||
async create(args: Prisma.DepartmentCreateArgs) {
|
||||
if (args.data.parentId) {
|
||||
args.data.domainId = await this.setDomainId(args.data.parentId);
|
||||
}
|
||||
const result = await super.create(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async update(args: Prisma.DepartmentUpdateArgs) {
|
||||
if (args.data.parentId) {
|
||||
args.data.domainId = await this.setDomainId(args.data.parentId as string);
|
||||
}
|
||||
const result = await super.update(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除现有单位并清理DeptAncestry关系。
|
||||
* @param data - 用于删除现有单位的数据。
|
||||
* @returns 删除的单位对象。
|
||||
*/
|
||||
async softDeleteByIds(ids: string[]) {
|
||||
const descendantIds = await this.getDescendantIds(ids, true);
|
||||
const result = await super.softDeleteByIds(descendantIds);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.DELETED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定部门及其所有子部门的员工。
|
||||
* @param deptIds - 要获取员工ID的部门ID数组。
|
||||
* @returns 包含所有员工ID的数组。
|
||||
*/
|
||||
async getStaffsInDepts(deptIds: string[]) {
|
||||
const allDeptIds = await this.getDescendantIds(deptIds, true);
|
||||
return await getStaffsByDeptIds(Array.from(allDeptIds));
|
||||
}
|
||||
async getStaffIdsInDepts(deptIds: string[]) {
|
||||
const result = await this.getStaffsInDepts(deptIds);
|
||||
return result.map((s) => s.id);
|
||||
}
|
||||
/**
|
||||
* 根据部门名称列表和域ID获取多个部门的ID。
|
||||
*
|
||||
* @param {string[]} names - 部门名称列表
|
||||
* @param {string} domainId - 域ID
|
||||
* @returns {Promise<Record<string, string | null>>} - 返回一个对象,键为部门名称,值为部门ID或null
|
||||
*/
|
||||
async getDeptIdsByNames(
|
||||
names: string[],
|
||||
domainId: string,
|
||||
): Promise<Record<string, string | null>> {
|
||||
// 使用 Prisma 的 findMany 方法批量查询部门信息,优化性能
|
||||
const depts = await db.department.findMany({
|
||||
where: {
|
||||
// 查询条件:部门名称在给定的名称列表中
|
||||
name: { in: names },
|
||||
// 查询条件:部门在指定的域下(通过ancestors关系查询)
|
||||
ancestors: {
|
||||
some: {
|
||||
ancestorId: domainId,
|
||||
},
|
||||
},
|
||||
},
|
||||
// 选择查询的字段:只查询部门的id和name字段
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 创建一个Map对象,将部门名称映射到部门ID
|
||||
const deptMap = new Map(depts.map((dept) => [dept.name, dept.id]));
|
||||
|
||||
// 初始化结果对象,用于存储最终的结果
|
||||
const result: Record<string, string | null> = {};
|
||||
|
||||
// 遍历传入的部门名称列表
|
||||
for (const name of names) {
|
||||
// 从Map中获取部门ID,如果不存在则返回null
|
||||
result[name] = deptMap.get(name) || null;
|
||||
}
|
||||
|
||||
// 返回最终的结果对象
|
||||
return result;
|
||||
}
|
||||
|
||||
async getChildSimpleTree(
|
||||
data: z.infer<typeof DepartmentMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
const { domain, deptIds, rootId } = data;
|
||||
// 提取非空 deptIds
|
||||
const validDeptIds = deptIds?.filter((id) => id !== null) ?? [];
|
||||
const hasNullDeptId = deptIds?.includes(null) ?? false;
|
||||
const [childrenData, selfData] = await Promise.all([
|
||||
db.deptAncestry.findMany({
|
||||
where: {
|
||||
...(deptIds && {
|
||||
OR: [
|
||||
...(validDeptIds.length
|
||||
? [{ ancestorId: { in: validDeptIds } }]
|
||||
: []),
|
||||
...(hasNullDeptId ? [{ ancestorId: null }] : []),
|
||||
],
|
||||
}),
|
||||
ancestorId: rootId,
|
||||
relDepth: 1,
|
||||
descendant: { isDomain: domain },
|
||||
},
|
||||
include: {
|
||||
descendant: { include: { children: true, deptStaffs: true } },
|
||||
},
|
||||
orderBy: { descendant: { order: 'asc' } },
|
||||
}),
|
||||
deptIds
|
||||
? db.department.findMany({
|
||||
where: {
|
||||
...(deptIds && {
|
||||
OR: [
|
||||
...(validDeptIds.length
|
||||
? [{ id: { in: validDeptIds } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
isDomain: domain,
|
||||
},
|
||||
include: { children: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const children = childrenData
|
||||
.map(({ descendant }) => descendant)
|
||||
.filter(Boolean)
|
||||
.map(mapToDeptSimpleTree);
|
||||
const selfItems = selfData.map(mapToDeptSimpleTree);
|
||||
return getUniqueItems([...children, ...selfItems], 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父级部门简单树结构的异步方法
|
||||
*
|
||||
* @param data - 包含部门ID、域和根ID的输入参数
|
||||
* @returns 返回唯一的部门简单树结构数组
|
||||
*
|
||||
* 方法整体设计思路:
|
||||
* 1. 并行查询父级部门ancestry和自身部门数据
|
||||
* 2. 查询根节点的直接子节点
|
||||
* 3. 通过自定义过滤函数筛选超出根节点层级的祖先节点
|
||||
* 4. 将查询结果映射为简单树结构
|
||||
* 5. 去重并返回最终结果
|
||||
*/
|
||||
async getParentSimpleTree(
|
||||
data: z.infer<typeof DepartmentMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
// 解构输入参数
|
||||
const { deptIds, domain, rootId } = data;
|
||||
// 并行查询父级部门ancestry和自身部门数据
|
||||
// 使用Promise.all提高查询效率,减少等待时间
|
||||
const [parentData, selfData] = await Promise.all([
|
||||
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
|
||||
db.deptAncestry.findMany({
|
||||
where: {
|
||||
descendantId: { in: deptIds }, // 查询条件:descendant在给定的部门ID列表中
|
||||
ancestor: { isDomain: domain }, // 限定域
|
||||
},
|
||||
include: {
|
||||
ancestor: {
|
||||
include: {
|
||||
children: true, // 包含子节点信息
|
||||
parent: true, // 包含父节点信息
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
|
||||
}),
|
||||
|
||||
// 查询自身部门数据
|
||||
db.department.findMany({
|
||||
where: { id: { in: deptIds }, isDomain: domain },
|
||||
include: { children: true }, // 包含子节点信息
|
||||
orderBy: { order: 'asc' }, // 按顺序升序排序
|
||||
}),
|
||||
]);
|
||||
|
||||
// 查询根节点的直接子节点
|
||||
const rootChildren = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: rootId, // 祖先ID为根ID
|
||||
descendant: { isDomain: domain }, // 限定域
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 判断祖先节点是否超出根节点层级的函数
|
||||
*
|
||||
* @param ancestor - 祖先节点
|
||||
* @returns 是否超出根节点层级
|
||||
*/
|
||||
const isDirectDescendantOfRoot = (ancestor: DeptAncestry): boolean => {
|
||||
return (
|
||||
rootChildren.findIndex(
|
||||
(child) => child.descendantId === ancestor.ancestorId,
|
||||
) !== -1
|
||||
);
|
||||
};
|
||||
|
||||
// 处理父级节点:过滤并映射为简单树结构
|
||||
const parents = parentData
|
||||
.map(({ ancestor }) => ancestor) // 提取祖先节点
|
||||
.filter(
|
||||
(ancestor) => ancestor && isDirectDescendantOfRoot(ancestor as any),
|
||||
) // 过滤有效且超出根节点层级的节点
|
||||
.map(mapToDeptSimpleTree); // 映射为简单树结构
|
||||
|
||||
// 处理自身节点:映射为简单树结构
|
||||
const selfItems = selfData.map(mapToDeptSimpleTree);
|
||||
|
||||
// 合并并去重父级和自身节点,返回唯一项
|
||||
return getUniqueItems([...parents, ...selfItems], 'id');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
DeptSimpleTreeNode,
|
||||
TreeDataNode,
|
||||
} from '@nice/common';
|
||||
|
||||
/**
|
||||
* 将部门数据映射为DeptSimpleTreeNode结构
|
||||
* @param department 部门数据对象
|
||||
* @returns 返回格式化后的DeptSimpleTreeNode对象
|
||||
* 数据结构说明:
|
||||
* - id: 部门唯一标识
|
||||
* - key: 部门唯一标识,通常用于React中的key属性
|
||||
* - value: 部门唯一标识,通常用于表单值
|
||||
* - title: 部门名称
|
||||
* - order: 部门排序值
|
||||
* - pId: 父部门ID,用于构建树形结构
|
||||
* - isLeaf: 是否为叶子节点,即该部门是否没有子部门
|
||||
* - hasStaff: 该部门是否包含员工
|
||||
*/
|
||||
export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode {
|
||||
return {
|
||||
id: department.id,
|
||||
key: department.id,
|
||||
value: department.id,
|
||||
title: department.name,
|
||||
order: department.order,
|
||||
pId: department.parentId,
|
||||
isLeaf: !Boolean(department.children?.length),
|
||||
hasStaff: department?.deptStaffs?.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门ID列表获取相关员工信息
|
||||
* @param ids 部门ID列表
|
||||
* @returns 返回与指定部门相关的员工ID列表
|
||||
* 算法说明:
|
||||
* - 使用数据库查询方法findMany,根据部门ID列表查询相关部门的员工信息
|
||||
* - 使用flatMap将查询结果扁平化,提取所有员工ID
|
||||
*/
|
||||
export async function getStaffsByDeptIds(ids: string[]) {
|
||||
const depts = await db.department.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
deptStaffs: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return depts.flatMap((dept) => dept.deptStaffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取唯一的员工ID列表
|
||||
* @param params 参数对象,包含部门ID列表、员工ID列表和员工信息
|
||||
* @returns 返回唯一的员工ID列表
|
||||
* 算法说明:
|
||||
* - 根据部门ID列表获取相关员工ID
|
||||
* - 将部门员工ID与传入的员工ID列表合并,并使用Set去重
|
||||
* - 如果传入了员工信息,则从结果中移除该员工的ID
|
||||
* - 最终返回去重后的员工ID列表
|
||||
*/
|
||||
export async function extractUniqueStaffIds(params: {
|
||||
deptIds?: string[];
|
||||
staffIds?: string[];
|
||||
staff?: UserProfile;
|
||||
}): Promise<string[]> {
|
||||
const { deptIds, staff, staffIds } = params;
|
||||
const deptStaffs = await getStaffsByDeptIds(deptIds);
|
||||
const result = new Set(deptStaffs.map((item) => item.id).concat(staffIds));
|
||||
if (staff) {
|
||||
result.delete(staff.id);
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { MessageService } from './message.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db, VisitType } from '@nice/common';
|
||||
|
||||
@Controller('message')
|
||||
export class MessageController {
|
||||
constructor(private readonly messageService: MessageService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-last-one')
|
||||
async findLastOne(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-unreaded')
|
||||
async findUnreaded(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findMany({
|
||||
where: {
|
||||
visits: {
|
||||
none: {
|
||||
id: staffId,
|
||||
type: VisitType.READED
|
||||
},
|
||||
},
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('count-unreaded')
|
||||
async countUnreaded(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findMany({
|
||||
where: {
|
||||
visits: {
|
||||
none: {
|
||||
id: staffId,
|
||||
type: VisitType.READED
|
||||
},
|
||||
},
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MessageService } from './message.service';
|
||||
import { MessageRouter } from './message.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { MessageController } from './message.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [MessageService, MessageRouter, TrpcService],
|
||||
exports: [MessageService, MessageRouter],
|
||||
controllers: [MessageController],
|
||||
})
|
||||
export class MessageModule { }
|
|
@ -0,0 +1,39 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { MessageService } from './message.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
const MessageUncheckedCreateInputSchema: ZodType<Prisma.MessageUncheckedCreateInput> = z.any()
|
||||
const MessageWhereInputSchema: ZodType<Prisma.MessageWhereInput> = z.any()
|
||||
const MessageSelectSchema: ZodType<Prisma.MessageSelect> = z.any()
|
||||
@Injectable()
|
||||
export class MessageRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly messageService: MessageService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(MessageUncheckedCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.create({ data: input }, { staff });
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: MessageWhereInputSchema.nullish(),
|
||||
select: MessageSelectSchema.nullish()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
getUnreadCount: this.trpc.protectProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.getUnreadCount(staff);
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { UserProfile, db, Prisma, VisitType, ObjectType } from '@nice/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { setMessageRelation } from './utils';
|
||||
@Injectable()
|
||||
export class MessageService extends BaseService<Prisma.MessageDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.MESSAGE);
|
||||
}
|
||||
async create(args: Prisma.MessageCreateArgs, params?: { tx?: Prisma.MessageDelegate, staff?: UserProfile }) {
|
||||
args.data!.senderId = params?.staff?.id;
|
||||
args.include = {
|
||||
receivers: {
|
||||
select: { id: true, registerToken: true, username: true }
|
||||
}
|
||||
}
|
||||
const result = await super.create(args);
|
||||
EventBus.emit("dataChanged", {
|
||||
type: ObjectType.MESSAGE,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result
|
||||
})
|
||||
return result
|
||||
}
|
||||
async findManyWithCursor(
|
||||
args: Prisma.MessageFindManyArgs,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
let { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setMessageRelation(item, staff);
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
async getUnreadCount(staff?: UserProfile) {
|
||||
const count = await db.message.count({
|
||||
where: {
|
||||
receivers: { some: { id: staff?.id } },
|
||||
visits: {
|
||||
none: {
|
||||
visitorId: staff?.id,
|
||||
type: VisitType.READED
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { Message, UserProfile, VisitType, db } from "@nice/common"
|
||||
export async function setMessageRelation(
|
||||
data: Message,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
|
||||
const readed =
|
||||
(await db.visit.count({
|
||||
where: {
|
||||
messageId: data.id,
|
||||
type: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
|
||||
|
||||
Object.assign(data, {
|
||||
readed
|
||||
})
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { PostService } from './post.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('post')
|
||||
export class PostController {
|
||||
constructor(private readonly postService: PostService) {}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
|
||||
import { QueueModule } from '@server/queue/queue.module';
|
||||
import { MessageModule } from '../message/message.module';
|
||||
import { PostRouter } from './post.router';
|
||||
import { PostController } from './post.controller';
|
||||
import { PostService } from './post.service';
|
||||
import { RoleMapModule } from '../rbac/rbac.module';
|
||||
|
||||
@Module({
|
||||
imports: [QueueModule, RoleMapModule, MessageModule],
|
||||
providers: [PostService, PostRouter, TrpcService, DepartmentService],
|
||||
exports: [PostRouter, PostService],
|
||||
controllers: [PostController],
|
||||
})
|
||||
export class PostModule {}
|
|
@ -0,0 +1,106 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { CourseMethodSchema, Prisma } from '@nice/common';
|
||||
import { PostService } from './post.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const PostCreateArgsSchema: ZodType<Prisma.PostCreateArgs> = z.any();
|
||||
const PostUpdateArgsSchema: ZodType<Prisma.PostUpdateArgs> = z.any();
|
||||
const PostFindFirstArgsSchema: ZodType<Prisma.PostFindFirstArgs> = z.any();
|
||||
const PostFindManyArgsSchema: ZodType<Prisma.PostFindManyArgs> = z.any();
|
||||
const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = z.any();
|
||||
const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any();
|
||||
const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any();
|
||||
const PostUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
|
||||
@Injectable()
|
||||
export class PostRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly postService: PostService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(PostCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.create(input, { staff });
|
||||
}),
|
||||
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
data: PostUpdateInputSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.softDeleteByIds(input.ids, input.data);
|
||||
}),
|
||||
restoreByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
args: PostUpdateInputSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.restoreByIds(input.ids, input.args);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(PostUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.update(input, staff);
|
||||
}),
|
||||
findById: this.trpc.protectProcedure
|
||||
.input(z.object({ id: z.string(), args: PostFindFirstArgsSchema }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findById(input.id, input.args);
|
||||
}),
|
||||
findMany: this.trpc.protectProcedure
|
||||
.input(PostFindManyArgsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findMany(input);
|
||||
}),
|
||||
|
||||
findFirst: this.trpc.procedure
|
||||
.input(PostFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff, ip } = ctx;
|
||||
// 从请求中获取 IP
|
||||
|
||||
return await this.postService.findFirst(input, staff, ip);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(PostDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.postService.deleteMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: PostWhereInputSchema.nullish(),
|
||||
select: PostSelectSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.postService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
where: PostWhereInputSchema.optional(),
|
||||
select: PostSelectSchema.optional(),
|
||||
}),
|
||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.postService.findManyWithPagination(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
Prisma,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
Post,
|
||||
PostType,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
ObjectType,
|
||||
|
||||
CourseMethodSchema,
|
||||
} from '@nice/common';
|
||||
import { MessageService } from '../message/message.service';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { setPostRelation } from './utils';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { BaseTreeService } from '../base/base.tree.service';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||
constructor(
|
||||
private readonly messageService: MessageService,
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(db, ObjectType.POST, 'postAncestry', true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async create(
|
||||
args: Prisma.PostCreateArgs,
|
||||
params?: { staff?: UserProfile; tx?: Prisma.TransactionClient },
|
||||
) {
|
||||
args.data.authorId = params?.staff?.id;
|
||||
const result = await super.create(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.POST,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
|
||||
args.data.authorId = staff?.id;
|
||||
const result = await super.update(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.POST,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async findFirst(
|
||||
args?: Prisma.PostFindFirstArgs,
|
||||
staff?: UserProfile,
|
||||
clientIp?: string,
|
||||
) {
|
||||
const transDto = await this.wrapResult(
|
||||
super.findFirst(args),
|
||||
async (result) => {
|
||||
if (result) {
|
||||
await setPostRelation({ data: result, staff });
|
||||
await this.setPerms(result, staff);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
return transDto;
|
||||
}
|
||||
|
||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
if (!args.where) args.where = {};
|
||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
const { items } = result;
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
await setPostRelation({ data: item, staff });
|
||||
await this.setPerms(item, staff);
|
||||
}),
|
||||
);
|
||||
return { ...result, items };
|
||||
});
|
||||
}
|
||||
|
||||
protected async setPerms(data: Post, staff?: UserProfile) {
|
||||
if (!staff) return;
|
||||
const perms: ResPerm = {
|
||||
delete: false,
|
||||
};
|
||||
const isMySelf = data?.authorId === staff?.id;
|
||||
const isDomain = staff.domainId === data.domainId;
|
||||
const setManagePermissions = (perms: ResPerm) => {
|
||||
Object.assign(perms, {
|
||||
delete: true,
|
||||
// edit: true,
|
||||
});
|
||||
};
|
||||
if (isMySelf) {
|
||||
perms.delete = true;
|
||||
// perms.edit = true;
|
||||
}
|
||||
staff.permissions.forEach((permission) => {
|
||||
switch (permission) {
|
||||
case RolePerms.MANAGE_ANY_POST:
|
||||
setManagePermissions(perms);
|
||||
break;
|
||||
case RolePerms.MANAGE_DOM_POST:
|
||||
if (isDomain) {
|
||||
setManagePermissions(perms);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
Object.assign(data, { perms });
|
||||
}
|
||||
async preFilter(OR?: Prisma.PostWhereInput[], staff?: UserProfile) {
|
||||
const preFilter = (await this.getPostPreFilter(staff)) || [];
|
||||
const outOR = OR ? [...OR, ...preFilter].filter(Boolean) : preFilter;
|
||||
return outOR?.length > 0 ? outOR : undefined;
|
||||
}
|
||||
async getPostPreFilter(staff?: UserProfile) {
|
||||
if (!staff) return;
|
||||
const { deptId, domainId } = staff;
|
||||
if (
|
||||
staff.permissions.includes(RolePerms.READ_ANY_POST) ||
|
||||
staff.permissions.includes(RolePerms.MANAGE_ANY_POST)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const parentDeptIds =
|
||||
(await this.departmentService.getAncestorIds(staff.deptId)) || [];
|
||||
const orCondition: Prisma.PostWhereInput[] = [
|
||||
staff?.id && {
|
||||
authorId: staff.id,
|
||||
},
|
||||
staff?.id && {
|
||||
watchableStaffs: {
|
||||
some: {
|
||||
id: staff.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
deptId && {
|
||||
watchableDepts: {
|
||||
some: {
|
||||
id: {
|
||||
in: parentDeptIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
watchableStaffs: {
|
||||
none: {}, // 匹配 watchableStaffs 为空
|
||||
},
|
||||
},
|
||||
{
|
||||
watchableDepts: {
|
||||
none: {}, // 匹配 watchableDepts 为空
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
if (orCondition?.length > 0) return orCondition;
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
db,
|
||||
Post,
|
||||
PostType,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
} from '@nice/common';
|
||||
|
||||
export async function setPostRelation(params: {
|
||||
data: Post;
|
||||
staff?: UserProfile;
|
||||
}) {
|
||||
const { data, staff } = params;
|
||||
const limitedComments = await db.post.findMany({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
},
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
const commentsCount = await db.post.count({
|
||||
where: {
|
||||
parentId: data.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
},
|
||||
});
|
||||
const readed =
|
||||
(await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
type: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
const readedCount = await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
type: VisitType.READED,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(data, {
|
||||
readed,
|
||||
readedCount,
|
||||
limitedComments,
|
||||
commentsCount,
|
||||
// trouble
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
import { RoleRouter } from './role.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { RoleService } from './role.service';
|
||||
import { RoleMapRouter } from './rolemap.router';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter],
|
||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter]
|
||||
})
|
||||
export class RoleMapModule { }
|
|
@ -0,0 +1,88 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { RoleService } from './role.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any()
|
||||
const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any()
|
||||
const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any()
|
||||
const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any()
|
||||
const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any()
|
||||
const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any()
|
||||
const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any()
|
||||
const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any()
|
||||
const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any();
|
||||
@Injectable()
|
||||
export class RoleRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleService: RoleService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(RoleCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.create(input, staff);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(RoleUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.update(input, staff);
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.roleService.createMany({ data: input }, staff);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
data: RoleUpdateInputSchema.optional()
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.roleService.softDeleteByIds(input.ids, input.data);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleService.findFirst(input);
|
||||
}),
|
||||
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.roleService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().optional(),
|
||||
where: RoleWhereInputSchema.optional(),
|
||||
select: RoleSelectSchema.optional()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.findManyWithCursor(input);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(z.object({
|
||||
page: z.number(),
|
||||
pageSize: z.number().optional(),
|
||||
where: RoleWhereInputSchema.optional(),
|
||||
select: RoleSelectSchema.optional()
|
||||
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleService.findManyWithPagination(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nice/common";
|
||||
import { RowCacheService } from "../base/row-cache.service";
|
||||
import { isFieldCondition, LogicalCondition } from "../base/sql-builder";
|
||||
import { z } from "zod";
|
||||
export class RoleRowService extends RowCacheService {
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof RowRequestSchema>,
|
||||
staff?: UserProfile
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request)
|
||||
if (isFieldCondition(condition))
|
||||
return {}
|
||||
const baseModelCondition: LogicalCondition[] = [{
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: "blank",
|
||||
type: "date"
|
||||
}]
|
||||
condition.AND = [...baseModelCondition, ...condition.AND!]
|
||||
return condition
|
||||
}
|
||||
createUnGroupingRowSelect(): string[] {
|
||||
return [
|
||||
`${this.tableName}.id AS id`,
|
||||
`${this.tableName}.name AS name`,
|
||||
`${this.tableName}.system AS system`,
|
||||
`${this.tableName}.permissions AS permissions`
|
||||
];
|
||||
}
|
||||
protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
|
||||
if (!data.id)
|
||||
return data
|
||||
const roleMaps = await db.roleMap.findMany({
|
||||
where: {
|
||||
roleId: data.id
|
||||
}
|
||||
})
|
||||
const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId)
|
||||
const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId)
|
||||
const depts = await db.department.findMany({ where: { id: { in: deptIds } } })
|
||||
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } })
|
||||
const result = { ...data, depts, staffs }
|
||||
return result
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, RoleMethodSchema, ObjectType, Prisma } from '@nice/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
@Injectable()
|
||||
export class RoleService extends BaseService<Prisma.RoleDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.ROLE);
|
||||
}
|
||||
/**
|
||||
* 批量删除角色
|
||||
* @param data 包含要删除的角色ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async softDeleteByIds(ids: string[], data?: Prisma.RoleUpdateInput) {
|
||||
await db.roleMap.deleteMany({
|
||||
where: {
|
||||
roleId: {
|
||||
in: ids,
|
||||
},
|
||||
},
|
||||
});
|
||||
return await super.softDeleteByIds(ids, data);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import {
|
||||
ObjectType,
|
||||
RoleMapMethodSchema,
|
||||
} from '@nice/common';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleMapRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleMapService: RoleMapService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
deleteAllRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.deleteWithObject)
|
||||
.mutation(({ input }) =>
|
||||
this.roleMapService.deleteAllRolesForObject(input),
|
||||
),
|
||||
setRoleForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.create)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
|
||||
setRoleForObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRoleForObjects)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObjects(input)),
|
||||
addRoleForObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRoleForObjects)
|
||||
.mutation(({ input }) => this.roleMapService.addRoleForObjects(input)),
|
||||
setRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRolesForObject)
|
||||
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)),
|
||||
|
||||
getPermsForObject: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getPermsForObject)
|
||||
.query(({ input }) => this.roleMapService.getPermsForObject(input)),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.deleteMany) // Assuming RoleMapMethodSchema.deleteMany is the Zod schema for batch deleting staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.roleMapService.deleteMany(input);
|
||||
}),
|
||||
|
||||
paginate: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.paginate) // Define the input schema for pagination
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.paginate(input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.update)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleMapService.update(input);
|
||||
}),
|
||||
getRoleMapDetail: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getRoleMapDetail)
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.getRoleMapDetail(input);
|
||||
}),
|
||||
getRows: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleMapService.getRows(input, staff);
|
||||
}),
|
||||
getStaffsNotMap: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getStaffsNotMap)
|
||||
.query(async ({ input }) => {
|
||||
return this.roleMapService.getStaffsNotMap(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,316 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
RoleMapMethodSchema,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
RowModelRequest,
|
||||
UserProfile,
|
||||
} from '@nice/common';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { RowModelService } from '../base/row-model.service';
|
||||
import { isFieldCondition } from '../base/sql-builder';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class RoleMapService extends RowModelService {
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN staff ON staff.id = ${this.tableName}.object_id`,
|
||||
`LEFT JOIN department ON department.id = staff.dept_id`,
|
||||
];
|
||||
}
|
||||
createUnGroupingRowSelect(): string[] {
|
||||
return [
|
||||
`${this.tableName}.id AS id`,
|
||||
`${this.tableName}.object_id AS object_id`,
|
||||
`${this.tableName}.role_id AS role_id`,
|
||||
`${this.tableName}.domain_id AS domain_id`,
|
||||
`${this.tableName}.object_type AS object_type`,
|
||||
`staff.officer_id AS staff_officer_id`,
|
||||
`staff.username AS staff_username`,
|
||||
`department.name AS department_name`,
|
||||
`staff.showname AS staff_`,
|
||||
];
|
||||
}
|
||||
|
||||
constructor(private readonly departmentService: DepartmentService) {
|
||||
super('rolemap');
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof RoleMapMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const { roleId, domainId } = request;
|
||||
// Base conditions
|
||||
let condition = super.createGetRowsFilters(request, staff);
|
||||
if (isFieldCondition(condition)) return;
|
||||
// Adding conditions based on parameters existence
|
||||
if (roleId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.role_id`,
|
||||
value: roleId,
|
||||
op: 'equals',
|
||||
});
|
||||
}
|
||||
if (domainId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
value: domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
}
|
||||
return condition;
|
||||
}
|
||||
|
||||
protected async getRowDto(
|
||||
row: any,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
if (!row.id) return row;
|
||||
return row;
|
||||
}
|
||||
/**
|
||||
* 删除某对象的所有角色
|
||||
* @param data 包含对象ID的数据
|
||||
* @returns 删除结果
|
||||
*/
|
||||
async deleteAllRolesForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.deleteWithObject>,
|
||||
) {
|
||||
const { objectId } = data;
|
||||
return await db.roleMap.deleteMany({
|
||||
where: {
|
||||
objectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 为某对象设置一个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射
|
||||
*/
|
||||
async setRoleForObject(data: z.infer<typeof RoleMapMethodSchema.create>) {
|
||||
return await db.roleMap.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 批量为多个对象创建角色映射
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async setRoleForObjects(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRoleForObjects>,
|
||||
) {
|
||||
const { domainId, roleId, objectIds, objectType } = data;
|
||||
const roleMaps = objectIds.map((id) => ({
|
||||
domainId,
|
||||
objectId: id,
|
||||
roleId,
|
||||
objectType,
|
||||
}));
|
||||
// 开启事务
|
||||
const result = await db.$transaction(async (prisma) => {
|
||||
// 首先,删除现有的角色映射
|
||||
await prisma.roleMap.deleteMany({
|
||||
where: {
|
||||
domainId,
|
||||
roleId,
|
||||
objectType,
|
||||
},
|
||||
});
|
||||
// 然后,创建新的角色映射
|
||||
return await prisma.roleMap.createManyAndReturn({
|
||||
data: roleMaps,
|
||||
});
|
||||
});
|
||||
const wrapResult = Promise.all(result.map(async item => {
|
||||
const staff = await db.staff.findMany({
|
||||
include: { department: true },
|
||||
where: {
|
||||
id: item.objectId
|
||||
}
|
||||
})
|
||||
return { ...item, staff }
|
||||
}))
|
||||
return wrapResult;
|
||||
}
|
||||
async addRoleForObjects(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRoleForObjects>,
|
||||
) {
|
||||
const { domainId, roleId, objectIds, objectType } = data;
|
||||
const objects = await db.roleMap.findMany({
|
||||
where: { domainId, roleId, objectType },
|
||||
});
|
||||
data.objectIds = Array.from(
|
||||
new Set([...objectIds, ...objects.map((obj) => obj.objectId)]),
|
||||
);
|
||||
const result = this.setRoleForObjects(data);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 为某对象设置多个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async setRolesForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRolesForObject>,
|
||||
) {
|
||||
const { domainId, objectId, roleIds, objectType } = data;
|
||||
const roleMaps = roleIds.map((id) => ({
|
||||
domainId,
|
||||
objectId,
|
||||
roleId: id,
|
||||
objectType,
|
||||
}));
|
||||
|
||||
return await db.roleMap.createMany({ data: roleMaps });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某对象的权限
|
||||
* @param data 包含域ID、部门ID和对象ID的数据
|
||||
* @returns 用户角色的权限列表
|
||||
*/
|
||||
async getPermsForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.getPermsForObject>,
|
||||
) {
|
||||
const { domainId, deptId, staffId } = data;
|
||||
// Get all ancestor department IDs if deptId is provided.
|
||||
const ancestorDeptIds = deptId
|
||||
? await this.departmentService.getAncestorIds(deptId)
|
||||
: [];
|
||||
// Define a common filter for querying roles.
|
||||
const objectFilters: Prisma.RoleMapWhereInput[] = [
|
||||
{ objectId: staffId, objectType: ObjectType.STAFF },
|
||||
...(deptId || ancestorDeptIds.length > 0
|
||||
? [
|
||||
{
|
||||
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
||||
objectType: ObjectType.DEPARTMENT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
// Helper function to fetch roles based on domain ID.
|
||||
const fetchRoles = async (domainId: string) => {
|
||||
return db.roleMap.findMany({
|
||||
where: {
|
||||
AND: {
|
||||
domainId,
|
||||
OR: objectFilters,
|
||||
},
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
};
|
||||
// Fetch roles with and without specific domain IDs.
|
||||
const [nullDomainRoles, userRoles] = await Promise.all([
|
||||
fetchRoles(null),
|
||||
fetchRoles(domainId),
|
||||
]);
|
||||
// Extract permissions from roles and return them.
|
||||
return [...userRoles, ...nullDomainRoles].flatMap(
|
||||
({ role }) => role.permissions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除角色映射
|
||||
* @param data 包含要删除的角色映射ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async deleteMany(data: z.infer<typeof RoleMapMethodSchema.deleteMany>) {
|
||||
const { ids } = data;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.',
|
||||
});
|
||||
}
|
||||
const rowData = await this.getRowByIds({ ids, extraCondition: {} });
|
||||
await db.roleMap.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
return rowData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取角色映射
|
||||
* @param data 包含分页信息的数据
|
||||
* @returns 分页结果,包含角色映射列表和总数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof RoleMapMethodSchema.paginate>) {
|
||||
const { page, pageSize, domainId, roleId } = data;
|
||||
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.roleMap.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
where: { domainId, roleId },
|
||||
}),
|
||||
db.roleMap.count({
|
||||
where: { domainId, roleId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
|
||||
return { items, totalCount };
|
||||
}
|
||||
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) {
|
||||
const { domainId, roleId } = data;
|
||||
let staffs = await db.staff.findMany({
|
||||
where: {
|
||||
domainId,
|
||||
},
|
||||
});
|
||||
const roleMaps = await db.roleMap.findMany({
|
||||
where: {
|
||||
domainId,
|
||||
roleId,
|
||||
objectType: ObjectType.STAFF,
|
||||
},
|
||||
});
|
||||
staffs = staffs.filter(
|
||||
(staff) =>
|
||||
roleMaps.findIndex((roleMap) => roleMap.objectId === staff.id) === -1,
|
||||
);
|
||||
return staffs;
|
||||
}
|
||||
/**
|
||||
* 更新角色映射
|
||||
* @param data 包含更新信息的数据
|
||||
* @returns 更新后的角色映射
|
||||
*/
|
||||
async update(data: z.infer<typeof RoleMapMethodSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
|
||||
const updatedRoleMap = await db.roleMap.update({
|
||||
where: { id },
|
||||
data: { ...others },
|
||||
});
|
||||
return updatedRoleMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色映射详情
|
||||
* @param data 包含角色ID和域ID的数据
|
||||
* @returns 角色映射详情,包含部门ID和员工ID列表
|
||||
*/
|
||||
async getRoleMapDetail(data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>) {
|
||||
const { roleId, domainId } = data;
|
||||
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
|
||||
|
||||
const deptIds = res
|
||||
.filter((item) => item.objectType === ObjectType.DEPARTMENT)
|
||||
.map((item) => item.objectId);
|
||||
const staffIds = res
|
||||
.filter((item) => item.objectType === ObjectType.STAFF)
|
||||
.map((item) => item.objectId);
|
||||
|
||||
return { deptIds, staffIds };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { PrismaClient, Resource } from '@prisma/client';
|
||||
import { ProcessResult, ResourceProcessor } from '../types';
|
||||
import { db, ResourceStatus } from '@nice/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
// Pipeline 类
|
||||
export class ResourceProcessingPipeline {
|
||||
private processors: ResourceProcessor[] = [];
|
||||
private logger = new Logger(ResourceProcessingPipeline.name);
|
||||
|
||||
constructor() {}
|
||||
|
||||
// 添加处理器
|
||||
addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline {
|
||||
this.processors.push(processor);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 执行处理管道
|
||||
async execute(resource: Resource): Promise<ProcessResult> {
|
||||
let currentResource = resource;
|
||||
try {
|
||||
this.logger.log(`开始处理资源: ${resource.id}`);
|
||||
|
||||
currentResource = await this.updateProcessStatus(
|
||||
resource.id,
|
||||
ResourceStatus.PROCESSING,
|
||||
);
|
||||
this.logger.log(`资源状态已更新为处理中`);
|
||||
|
||||
for (const processor of this.processors) {
|
||||
const processorName = processor.constructor.name;
|
||||
this.logger.log(`开始执行处理器: ${processorName}`);
|
||||
|
||||
currentResource = await this.updateProcessStatus(
|
||||
currentResource.id,
|
||||
processor.constructor.name as ResourceStatus,
|
||||
);
|
||||
|
||||
currentResource = await processor.process(currentResource);
|
||||
this.logger.log(`处理器 ${processorName} 执行完成`);
|
||||
|
||||
currentResource = await db.resource.update({
|
||||
where: { id: currentResource.id },
|
||||
data: currentResource,
|
||||
});
|
||||
}
|
||||
|
||||
currentResource = await this.updateProcessStatus(
|
||||
currentResource.id,
|
||||
ResourceStatus.PROCESSED,
|
||||
);
|
||||
this.logger.log(
|
||||
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.meta)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
resource: currentResource,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`资源 ${resource.id} 处理失败:`, error);
|
||||
|
||||
currentResource = await this.updateProcessStatus(
|
||||
currentResource.id,
|
||||
ResourceStatus.PROCESS_FAILED,
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
resource: currentResource,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
private async updateProcessStatus(
|
||||
resourceId: string,
|
||||
status: ResourceStatus,
|
||||
): Promise<Resource> {
|
||||
return db.resource.update({
|
||||
where: { id: resourceId },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import path, { dirname } from "path";
|
||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types";
|
||||
import { Resource, ResourceStatus, db } from "@nice/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import fs from 'fs/promises';
|
||||
|
||||
export abstract class BaseProcessor implements ResourceProcessor {
|
||||
constructor() { }
|
||||
protected logger = new Logger(BaseProcessor.name)
|
||||
|
||||
abstract process(resource: Resource): Promise<Resource>
|
||||
protected createOutputDir(filepath: string, subdirectory: string = 'assets'): string {
|
||||
const outputDir = path.join(
|
||||
path.dirname(filepath),
|
||||
subdirectory,
|
||||
);
|
||||
fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`));
|
||||
|
||||
return outputDir;
|
||||
|
||||
}
|
||||
}
|
||||
//
|
|
@ -0,0 +1,62 @@
|
|||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types';
|
||||
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||
import { getUploadFilePath } from '@server/utils/file';
|
||||
import { BaseProcessor } from './BaseProcessor';
|
||||
|
||||
export class ImageProcessor extends BaseProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(resource: Resource): Promise<Resource> {
|
||||
const { url } = resource;
|
||||
const filepath = getUploadFilePath(url);
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.filetype?.startsWith('image/')) {
|
||||
this.logger.log(`Skipping non-image resource: ${resource.id}`);
|
||||
return resource;
|
||||
}
|
||||
try {
|
||||
const image = sharp(filepath);
|
||||
const metadata = await image.metadata();
|
||||
if (!metadata) {
|
||||
throw new Error(`Failed to get metadata for image: ${url}`);
|
||||
}
|
||||
// Create WebP compressed version
|
||||
const compressedDir = this.createOutputDir(filepath, 'compressed');
|
||||
const compressedPath = path.join(
|
||||
compressedDir,
|
||||
`${path.basename(filepath, path.extname(filepath))}.webp`,
|
||||
);
|
||||
await image
|
||||
.webp({
|
||||
quality: 80,
|
||||
lossless: false,
|
||||
effort: 5, // Range 0-6, higher means slower but better compression
|
||||
})
|
||||
.toFile(compressedPath);
|
||||
const imageMeta: ImageMetadata = {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
orientation: metadata.orientation,
|
||||
space: metadata.space,
|
||||
hasAlpha: metadata.hasAlpha,
|
||||
};
|
||||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...imageMeta,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return updatedResource;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to process image: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
import path, { dirname } from 'path';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
|
||||
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||
import { getUploadFilePath } from '@server/utils/file';
|
||||
import fs from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { BaseProcessor } from './BaseProcessor';
|
||||
|
||||
export class VideoProcessor extends BaseProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
async process(resource: Resource): Promise<Resource> {
|
||||
const { url } = resource;
|
||||
const filepath = getUploadFilePath(url);
|
||||
this.logger.log(
|
||||
`Processing video for resource ID: ${resource.id}, File ID: ${url}`,
|
||||
);
|
||||
|
||||
const originMeta = resource.meta as unknown as FileMetadata;
|
||||
if (!originMeta.filetype?.startsWith('video/')) {
|
||||
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
||||
return resource;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamDir = this.createOutputDir(filepath, 'stream');
|
||||
|
||||
const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([
|
||||
this.generateM3U8Stream(filepath, streamDir),
|
||||
this.getVideoMetadata(filepath),
|
||||
this.generateVideoCover(filepath, dirname(filepath)),
|
||||
]);
|
||||
const videoMeta: VideoMetadata = {
|
||||
...videoMetadata,
|
||||
coverUrl: coverUrl,
|
||||
};
|
||||
const updatedResource = await db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
meta: {
|
||||
...originMeta,
|
||||
...videoMeta,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`Successfully processed video for resource ID: ${resource.id}`,
|
||||
);
|
||||
return updatedResource;
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`,
|
||||
);
|
||||
throw new Error(`Failed to process video: ${error.message}`);
|
||||
}
|
||||
}
|
||||
private async generateVideoCover(
|
||||
filepath: string,
|
||||
outputDir: string,
|
||||
): Promise<string> {
|
||||
this.logger.log(`Generating video cover for: ${filepath}`);
|
||||
const jpgCoverPath = path.join(outputDir, 'cover.jpg');
|
||||
const webpCoverPath = path.join(outputDir, 'cover.webp');
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(filepath)
|
||||
.on('end', async () => {
|
||||
try {
|
||||
// 使用 Sharp 将 JPG 转换为 WebP
|
||||
await sharp(jpgCoverPath)
|
||||
.webp({ quality: 80 }) // 设置 WebP 压缩质量
|
||||
.toFile(webpCoverPath);
|
||||
|
||||
// 删除临时 JPG 文件
|
||||
await fs.unlink(jpgCoverPath);
|
||||
this.logger.log(`Video cover generated at: ${webpCoverPath}`);
|
||||
resolve(path.basename(webpCoverPath));
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Error converting cover to WebP: ${error.message}`,
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
.on('error', (err) => {
|
||||
this.logger.error(`Error generating video cover: ${err.message}`);
|
||||
reject(err);
|
||||
})
|
||||
.screenshots({
|
||||
count: 1,
|
||||
folder: outputDir,
|
||||
filename: 'cover.jpg',
|
||||
size: '640x360',
|
||||
});
|
||||
});
|
||||
}
|
||||
private async getVideoDuration(filepath: string): Promise<number> {
|
||||
this.logger.log(`Getting video duration for file: ${filepath}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
||||
if (err) {
|
||||
this.logger.error(`Error getting video duration: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const duration = metadata.format.duration || 0;
|
||||
this.logger.log(`Video duration: ${duration} seconds`);
|
||||
resolve(duration);
|
||||
});
|
||||
});
|
||||
}
|
||||
private async generateM3U8Stream(
|
||||
filepath: string,
|
||||
outputDir: string,
|
||||
): Promise<string> {
|
||||
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
||||
this.logger.log(
|
||||
`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`,
|
||||
);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
ffmpeg(filepath)
|
||||
.outputOptions([
|
||||
// Improved video encoding settings
|
||||
'-c:v libx264',
|
||||
'-preset medium', // Balance between encoding speed and compression
|
||||
'-crf 23', // Constant Rate Factor for quality
|
||||
'-profile:v high', // Higher profile for better compression
|
||||
'-level:v 4.1', // Updated level for better compatibility
|
||||
// Parallel processing and performance
|
||||
'-threads 0', // Auto-detect optimal thread count
|
||||
'-x264-params keyint=48:min-keyint=48', // More precise GOP control
|
||||
// HLS specific optimizations
|
||||
'-hls_time 4', // Shorter segment duration for better adaptive streaming
|
||||
'-hls_list_size 0', // Keep all segments in playlist
|
||||
'-hls_flags independent_segments+delete_segments', // Allow segment cleanup
|
||||
// Additional encoding optimizations
|
||||
'-sc_threshold 0', // Disable scene change detection for more consistent segments
|
||||
'-max_muxing_queue_size 1024', // Increase muxing queue size
|
||||
// Output format
|
||||
'-f hls',
|
||||
])
|
||||
.output(m3u8Path)
|
||||
.on('start', (commandLine) => {
|
||||
this.logger.log(`Starting ffmpeg with command: ${commandLine}`);
|
||||
})
|
||||
.on('end', () => {
|
||||
this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`);
|
||||
resolve(m3u8Path);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`;
|
||||
this.logger.error(errorMessage);
|
||||
reject(new Error(errorMessage));
|
||||
})
|
||||
.run();
|
||||
});
|
||||
}
|
||||
private async getVideoMetadata(
|
||||
filepath: string,
|
||||
): Promise<Partial<VideoMetadata>> {
|
||||
this.logger.log(`Getting video metadata for file: ${filepath}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
||||
if (err) {
|
||||
this.logger.error(`Error getting video metadata: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const videoStream = metadata.streams.find(
|
||||
(stream) => stream.codec_type === 'video',
|
||||
);
|
||||
const audioStream = metadata.streams.find(
|
||||
(stream) => stream.codec_type === 'audio',
|
||||
);
|
||||
const videoMetadata: Partial<VideoMetadata> = {
|
||||
width: videoStream?.width || 0,
|
||||
height: videoStream?.height || 0,
|
||||
duration: metadata.format.duration || 0,
|
||||
videoCodec: videoStream?.codec_name || '',
|
||||
audioCodec: audioStream?.codec_name || '',
|
||||
};
|
||||
this.logger.log(
|
||||
`Extracted video metadata: ${JSON.stringify(videoMetadata)}`,
|
||||
);
|
||||
resolve(videoMetadata);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ResourceRouter } from './resource.router';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
@Module({
|
||||
exports: [ResourceRouter, ResourceService],
|
||||
providers: [ResourceRouter, ResourceService, TrpcService],
|
||||
})
|
||||
export class ResourceModule { }
|
|
@ -0,0 +1,70 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any()
|
||||
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> = z.any()
|
||||
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> = z.any()
|
||||
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> = z.any()
|
||||
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> = z.any()
|
||||
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any()
|
||||
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any()
|
||||
|
||||
@Injectable()
|
||||
export class ResourceRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly resourceService: ResourceService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(ResourceCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.create(input, { staff });
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.resourceService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(ResourceDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.resourceService.deleteMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: ResourceWhereInputSchema.nullish(),
|
||||
select: ResourceSelectSchema.nullish()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.findManyWithCursor(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
Resource,
|
||||
ResourceStatus,
|
||||
} from '@nice/common';
|
||||
|
||||
@Injectable()
|
||||
export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.RESOURCE);
|
||||
}
|
||||
async create(
|
||||
args: Prisma.ResourceCreateArgs,
|
||||
params?: { staff?: UserProfile },
|
||||
): Promise<Resource> {
|
||||
if (params?.staff) {
|
||||
args.data.ownerId = params?.staff?.id;
|
||||
}
|
||||
return super.create(args);
|
||||
}
|
||||
async softDeleteByFileId(fileId: string) {
|
||||
return this.update({
|
||||
where: {
|
||||
fileId,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { Resource } from "@nice/common";
|
||||
|
||||
export interface ResourceProcessor {
|
||||
process(resource: Resource): Promise<any>
|
||||
}
|
||||
export interface ProcessResult {
|
||||
success: boolean
|
||||
resource: Resource
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export interface BaseMetadata {
|
||||
size: number
|
||||
filetype: string
|
||||
filename: string
|
||||
extension: string
|
||||
modifiedAt: Date
|
||||
}
|
||||
/**
|
||||
* 图片特有元数据接口
|
||||
*/
|
||||
export interface ImageMetadata {
|
||||
width: number; // 图片宽度(px)
|
||||
height: number; // 图片高度(px)
|
||||
compressedUrl?: string;
|
||||
orientation?: number; // EXIF方向信息
|
||||
space?: string; // 色彩空间 (如: RGB, CMYK)
|
||||
hasAlpha?: boolean; // 是否包含透明通道
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频特有元数据接口
|
||||
*/
|
||||
export interface VideoMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频特有元数据接口
|
||||
*/
|
||||
export interface AudioMetadata {
|
||||
duration: number; // 音频时长(秒)
|
||||
bitrate?: number; // 比特率(bps)
|
||||
sampleRate?: number; // 采样率(Hz)
|
||||
channels?: number; // 声道数
|
||||
codec?: string; // 音频编码格式
|
||||
}
|
||||
|
||||
|
||||
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata
|
|
@ -0,0 +1,48 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { StaffService } from './staff.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('staff')
|
||||
export class StaffController {
|
||||
constructor(private readonly staffService: StaffService) {}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-by-id')
|
||||
async findById(@Query('id') id: string) {
|
||||
try {
|
||||
const result = await this.staffService.findById(id);
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@Get('find-by-dept')
|
||||
async findByDept(
|
||||
@Query('dept-id') deptId: string,
|
||||
@Query('domain-id') domainId: string,
|
||||
) {
|
||||
try {
|
||||
const result = await this.staffService.findByDept({ deptId, domainId });
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffRouter } from './staff.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffRowService } from './staff.row.service';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [StaffService, StaffRouter, TrpcService, StaffRowService],
|
||||
exports: [StaffService, StaffRouter, StaffRowService],
|
||||
controllers: [StaffController],
|
||||
})
|
||||
export class StaffModule { }
|
|
@ -0,0 +1,81 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { StaffService } from './staff.service'; // Adjust the import path as necessary
|
||||
import { StaffMethodSchema, Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { StaffRowService } from './staff.row.service';
|
||||
const StaffCreateArgsSchema: ZodType<Prisma.StaffCreateArgs> = z.any();
|
||||
const StaffUpdateArgsSchema: ZodType<Prisma.StaffUpdateArgs> = z.any();
|
||||
const StaffFindFirstArgsSchema: ZodType<Prisma.StaffFindFirstArgs> = z.any();
|
||||
const StaffDeleteManyArgsSchema: ZodType<Prisma.StaffDeleteManyArgs> = z.any();
|
||||
const StaffWhereInputSchema: ZodType<Prisma.StaffWhereInput> = z.any();
|
||||
const StaffSelectSchema: ZodType<Prisma.StaffSelect> = z.any();
|
||||
const StaffUpdateInputSchema: ZodType<Prisma.StaffUpdateInput> = z.any();
|
||||
const StaffFindManyArgsSchema: ZodType<Prisma.StaffFindManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class StaffRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly staffRowService: StaffRowService,
|
||||
) {}
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(StaffCreateArgsSchema) // Assuming StaffMethodSchema.create is the Zod schema for creating staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.staffService.create(input);
|
||||
}),
|
||||
|
||||
update: this.trpc.procedure
|
||||
.input(StaffUpdateArgsSchema) // Assuming StaffMethodSchema.update is the Zod schema for updating staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.staffService.update(input);
|
||||
}),
|
||||
updateUserDomain: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domainId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await this.staffService.updateUserDomain(input, ctx.staff);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
data: StaffUpdateInputSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.staffService.softDeleteByIds(input.ids, input.data);
|
||||
}),
|
||||
findByDept: this.trpc.procedure
|
||||
.input(StaffMethodSchema.findByDept)
|
||||
.query(async ({ input }) => {
|
||||
return await this.staffService.findByDept(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(StaffFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.staffService.findMany(input);
|
||||
}),
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(StaffMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.staffRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
findFirst: this.trpc.protectProcedure
|
||||
.input(StaffFindFirstArgsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.staffService.findFirst(input);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.staffService.updateOrder(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
ObjectType,
|
||||
StaffMethodSchema,
|
||||
UserProfile,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
Staff,
|
||||
RowModelRequest,
|
||||
} from '@nice/common';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { RowCacheService } from '../base/row-cache.service';
|
||||
import { z } from 'zod';
|
||||
import { isFieldCondition } from '../base/sql-builder';
|
||||
@Injectable()
|
||||
export class StaffRowService extends RowCacheService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(ObjectType.STAFF, false);
|
||||
}
|
||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
const result = super.createUnGroupingRowSelect(request).concat([
|
||||
`${this.tableName}.id AS id`,
|
||||
`${this.tableName}.username AS username`,
|
||||
`${this.tableName}.showname AS showname`,
|
||||
`${this.tableName}.avatar AS avatar`,
|
||||
`${this.tableName}.officer_id AS officer_id`,
|
||||
`${this.tableName}.phone_number AS phone_number`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.enabled AS enabled`,
|
||||
'dept.name AS dept_name',
|
||||
'domain.name AS domain_name',
|
||||
]);
|
||||
return result
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN department dept ON ${this.tableName}.dept_id = dept.id`,
|
||||
`LEFT JOIN department domain ON ${this.tableName}.domain_id = domain.id`,
|
||||
];
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof StaffMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
const { domainId, includeDeleted = false } = request;
|
||||
if (isFieldCondition(condition)) {
|
||||
return;
|
||||
}
|
||||
if (domainId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
value: domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
} else {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
if (!includeDeleted) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
type: 'date',
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
condition.OR = [];
|
||||
if (!staff.permissions.includes(RolePerms.MANAGE_ANY_STAFF)) {
|
||||
if (staff.permissions.includes(RolePerms.MANAGE_DOM_STAFF)) {
|
||||
condition.OR.push({
|
||||
field: 'dept.id',
|
||||
value: staff.domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
async getPermissionContext(id: string, staff: UserProfile) {
|
||||
const data = await db.staff.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deptId: true,
|
||||
domainId: true,
|
||||
},
|
||||
});
|
||||
const deptId = data?.deptId;
|
||||
const isFromSameDept = staff.deptIds?.includes(deptId);
|
||||
const domainChildDeptIds = await this.departmentService.getDescendantIds(
|
||||
staff.domainId, true
|
||||
);
|
||||
const belongsToDomain = domainChildDeptIds.includes(
|
||||
deptId,
|
||||
);
|
||||
return { isFromSameDept, belongsToDomain };
|
||||
}
|
||||
protected async setResPermissions(
|
||||
data: Staff,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const permissions: ResPerm = {};
|
||||
const { isFromSameDept, belongsToDomain } = await this.getPermissionContext(
|
||||
data.id,
|
||||
staff,
|
||||
);
|
||||
const setManagePermissions = (permissions: ResPerm) => {
|
||||
Object.assign(permissions, {
|
||||
read: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
});
|
||||
};
|
||||
staff.permissions.forEach((permission) => {
|
||||
switch (permission) {
|
||||
case RolePerms.MANAGE_ANY_STAFF:
|
||||
setManagePermissions(permissions);
|
||||
break;
|
||||
case RolePerms.MANAGE_DOM_STAFF:
|
||||
if (belongsToDomain) {
|
||||
setManagePermissions(permissions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
return { ...data, perm: permissions };
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
StaffMethodSchema,
|
||||
ObjectType,
|
||||
UserProfile,
|
||||
Prisma,
|
||||
} from '@nice/common';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { z } from 'zod';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import * as argon2 from 'argon2';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
|
||||
@Injectable()
|
||||
export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||
|
||||
constructor(private readonly departmentService: DepartmentService) {
|
||||
super(db, ObjectType.STAFF, true);
|
||||
}
|
||||
/**
|
||||
* 获取某一单位下所有staff的记录
|
||||
* @param deptId 单位的id
|
||||
* @returns 查到的staff记录
|
||||
*/
|
||||
async findByDept(data: z.infer<typeof StaffMethodSchema.findByDept>) {
|
||||
const { deptId, domainId } = data;
|
||||
const childDepts = await this.departmentService.getDescendantIds(deptId, true);
|
||||
const result = await db.staff.findMany({
|
||||
where: {
|
||||
deptId: { in: childDepts },
|
||||
domainId,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async create(args: Prisma.StaffCreateArgs) {
|
||||
const { data } = args;
|
||||
await this.validateUniqueFields(data);
|
||||
const createData = {
|
||||
...data,
|
||||
password: await argon2.hash((data.password || '123456') as string),
|
||||
};
|
||||
const result = await super.create({ ...args, data: createData });
|
||||
this.emitDataChangedEvent(result, CrudOperation.CREATED);
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.StaffUpdateArgs) {
|
||||
const { data, where } = args;
|
||||
await this.validateUniqueFields(data, where.id);
|
||||
const updateData = {
|
||||
...data,
|
||||
...(data.password && { password: await argon2.hash(data.password as string) })
|
||||
};
|
||||
const result = await super.update({ ...args, data: updateData });
|
||||
this.emitDataChangedEvent(result, CrudOperation.UPDATED);
|
||||
return result;
|
||||
}
|
||||
private async validateUniqueFields(data: any, excludeId?: string) {
|
||||
const uniqueFields = [
|
||||
{ field: 'officerId', errorMsg: (val: string) => `证件号为${val}的用户已存在` },
|
||||
{ field: 'phoneNumber', errorMsg: (val: string) => `手机号为${val}的用户已存在` },
|
||||
{ field: 'username', errorMsg: (val: string) => `帐号为${val}的用户已存在` }
|
||||
];
|
||||
for (const { field, errorMsg } of uniqueFields) {
|
||||
if (data[field]) {
|
||||
const count = await db.staff.count({
|
||||
where: {
|
||||
[field]: data[field],
|
||||
...(excludeId && { id: { not: excludeId } })
|
||||
}
|
||||
});
|
||||
if (count > 0) {
|
||||
throw new Error(errorMsg(data[field]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private emitDataChangedEvent(data: any, operation: CrudOperation) {
|
||||
EventBus.emit("dataChanged", {
|
||||
type: this.objectType,
|
||||
operation,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新员工DomainId
|
||||
* @param data 包含domainId对象
|
||||
* @returns 更新后的员工记录
|
||||
*/
|
||||
async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) {
|
||||
let { domainId } = data;
|
||||
if (staff.domainId !== domainId) {
|
||||
const result = await this.update({
|
||||
where: { id: staff.id },
|
||||
data: {
|
||||
domainId,
|
||||
deptId: null,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
return staff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// /**
|
||||
// * 根据关键词或ID集合查找员工
|
||||
// * @param data 包含关键词、域ID和ID集合的对象
|
||||
// * @returns 匹配的员工记录列表
|
||||
// */
|
||||
// async findMany(data: z.infer<typeof StaffMethodSchema.findMany>) {
|
||||
// const { keyword, domainId, ids, deptId, limit = 30 } = data;
|
||||
// const idResults = ids
|
||||
// ? await db.staff.findMany({
|
||||
// where: {
|
||||
// id: { in: ids },
|
||||
// deletedAt: null,
|
||||
// domainId,
|
||||
// deptId,
|
||||
// },
|
||||
// select: {
|
||||
// id: true,
|
||||
// showname: true,
|
||||
// username: true,
|
||||
// deptId: true,
|
||||
// domainId: true,
|
||||
// department: true,
|
||||
// domain: true,
|
||||
// },
|
||||
// })
|
||||
// : [];
|
||||
|
||||
// const mainResults = await db.staff.findMany({
|
||||
// where: {
|
||||
// deletedAt: null,
|
||||
// domainId,
|
||||
// deptId,
|
||||
// OR: (keyword || ids) && [
|
||||
// { showname: { contains: keyword } },
|
||||
// {
|
||||
// username: {
|
||||
// contains: keyword,
|
||||
// },
|
||||
// },
|
||||
// { phoneNumber: { contains: keyword } },
|
||||
// // {
|
||||
// // id: { in: ids },
|
||||
// // },
|
||||
// ],
|
||||
// },
|
||||
// select: {
|
||||
// id: true,
|
||||
// showname: true,
|
||||
// username: true,
|
||||
// deptId: true,
|
||||
// domainId: true,
|
||||
// department: true,
|
||||
// domain: true,
|
||||
// },
|
||||
// orderBy: { order: 'asc' },
|
||||
// take: limit !== -1 ? limit : undefined,
|
||||
// });
|
||||
// // Combine results, ensuring no duplicates
|
||||
// const combinedResults = [
|
||||
// ...mainResults,
|
||||
// ...idResults.filter(
|
||||
// (idResult) =>
|
||||
// !mainResults.some((mainResult) => mainResult.id === idResult.id),
|
||||
// ),
|
||||
// ];
|
||||
|
||||
// return combinedResults;
|
||||
// }
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('tax')
|
||||
export class TaxonomyController {
|
||||
constructor(private readonly taxService: TaxonomyService) {}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-by-id')
|
||||
async findById(@Query('id') id: string) {
|
||||
try {
|
||||
const result = await this.taxService.findById({ id });
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TaxonomyRouter } from './taxonomy.router';
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { TaxonomyController } from './taxonomy.controller';
|
||||
|
||||
@Module({
|
||||
providers: [TaxonomyRouter, TaxonomyService, TrpcService],
|
||||
exports: [TaxonomyRouter, TaxonomyService],
|
||||
controllers: [TaxonomyController],
|
||||
})
|
||||
export class TaxonomyModule {}
|
|
@ -0,0 +1,55 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { TaxonomyMethodSchema } from '@nice/common';
|
||||
|
||||
@Injectable()
|
||||
export class TaxonomyRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly taxonomyService: TaxonomyService,
|
||||
) { }
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.create)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.taxonomyService.create(input);
|
||||
}),
|
||||
findById: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.findById)
|
||||
.query(async ({ input }) => {
|
||||
return this.taxonomyService.findById(input);
|
||||
}),
|
||||
findBySlug: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.findBySlug)
|
||||
.query(async ({ input }) => {
|
||||
return this.taxonomyService.findBySlug(input);
|
||||
}),
|
||||
update: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.update)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.taxonomyService.update(input);
|
||||
}),
|
||||
delete: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.delete)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.taxonomyService.delete(input);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.deleteMany)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.taxonomyService.deleteMany(input);
|
||||
}),
|
||||
paginate: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.paginate!)
|
||||
.query(async ({ input }) => {
|
||||
return this.taxonomyService.paginate(input);
|
||||
}),
|
||||
getAll: this.trpc.procedure
|
||||
.input(TaxonomyMethodSchema.getAll)
|
||||
.query(async ({ input }) => {
|
||||
return this.taxonomyService.getAll(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, TaxonomyMethodSchema, Prisma } from '@nice/common';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class TaxonomyService {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 清除分页缓存,删除所有以'taxonomies:page:'开头的键
|
||||
*/
|
||||
private async invalidatePaginationCache() {
|
||||
deleteByPattern('taxonomies:page:*');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的分类记录
|
||||
* @param input 分类创建信息
|
||||
* @returns 新创建的分类记录
|
||||
*/
|
||||
async create(input: z.infer<typeof TaxonomyMethodSchema.create>) {
|
||||
// 获取当前分类数量,设置新分类的order值为count + 1
|
||||
const count = await db.taxonomy.count();
|
||||
const taxonomy = await db.taxonomy.create({
|
||||
data: { ...input, order: count + 1 },
|
||||
});
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await redis.del(`taxonomy:${taxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据name查找分类记录
|
||||
* @param input 包含分类name的对象
|
||||
* @returns 查找到的分类记录
|
||||
*/
|
||||
async findByName(input: z.infer<typeof TaxonomyMethodSchema.findByName>) {
|
||||
const { name } = input;
|
||||
const cacheKey = `taxonomy:${name}`;
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { name } });
|
||||
if (taxonomy) {
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(taxonomy));
|
||||
}
|
||||
return taxonomy;
|
||||
}
|
||||
async findBySlug(input: z.infer<typeof TaxonomyMethodSchema.findBySlug>) {
|
||||
const { slug } = input;
|
||||
const cacheKey = `taxonomy-slug:${slug}`;
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { slug } });
|
||||
if (taxonomy) {
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(taxonomy));
|
||||
}
|
||||
return taxonomy;
|
||||
}
|
||||
/**
|
||||
* 根据ID查找分类记录
|
||||
* @param input 包含分类ID的对象
|
||||
* @returns 查找到的分类记录
|
||||
*/
|
||||
async findById(input: z.infer<typeof TaxonomyMethodSchema.findById>) {
|
||||
const cacheKey = `taxonomy:${input.id}`;
|
||||
const cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { id: input.id } });
|
||||
if (taxonomy) {
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(taxonomy));
|
||||
}
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类记录
|
||||
* @param input 包含ID和其他更新字段的对象
|
||||
* @returns 更新后的分类记录
|
||||
*/
|
||||
async update(input: any) {
|
||||
const { id, ...data } = input;
|
||||
const updatedTaxonomy = await db.taxonomy.update({ where: { id }, data });
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await redis.del(`taxonomy:${updatedTaxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return updatedTaxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类记录(软删除)
|
||||
* @param input 包含分类ID的对象
|
||||
* @returns 删除后的分类记录
|
||||
*/
|
||||
async delete(input: any) {
|
||||
const deletedTaxonomy = await db.taxonomy.update({
|
||||
where: { id: input.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await redis.del(`taxonomy:${deletedTaxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return deletedTaxonomy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除分类记录(软删除)
|
||||
* @param input 包含要删除的分类ID数组的对象
|
||||
* @returns 删除操作结果,包括删除的记录数
|
||||
*/
|
||||
async deleteMany(input: any) {
|
||||
const { ids } = input;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.',
|
||||
});
|
||||
}
|
||||
const deletedTaxonomies = await db.taxonomy.updateMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
// 删除每个分类的缓存及分页缓存
|
||||
await Promise.all(
|
||||
ids.map(async (id: string) => redis.del(`taxonomy:${id}`)),
|
||||
);
|
||||
await this.invalidatePaginationCache();
|
||||
return { success: true, count: deletedTaxonomies.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询分类记录
|
||||
* @param input 包含分页参数的对象
|
||||
* @returns 分类列表及总记录数
|
||||
*/
|
||||
async paginate(input: any) {
|
||||
const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`;
|
||||
const cachedData = await redis.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
const { page, pageSize } = input;
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.taxonomy.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { order: 'asc' },
|
||||
where: { deletedAt: null },
|
||||
}),
|
||||
db.taxonomy.count({ where: { deletedAt: null } }),
|
||||
]);
|
||||
const result = { items, totalCount };
|
||||
|
||||
// 缓存结果并设置过期时间
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有未删除的分类记录
|
||||
* @returns 分类记录列表
|
||||
*/
|
||||
async getAll(input: z.infer<typeof TaxonomyMethodSchema.getAll>) {
|
||||
const { type } = input;
|
||||
let filter: Prisma.TaxonomyWhereInput = {
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (type !== undefined) {
|
||||
filter = {
|
||||
...filter,
|
||||
OR: [
|
||||
{ objectType: { has: type } }, // objectType 包含 type
|
||||
],
|
||||
};
|
||||
}
|
||||
return db.taxonomy.findMany({
|
||||
where: filter,
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
slug: true,
|
||||
objectType: true,
|
||||
order: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { TermService } from './term.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nice/common';
|
||||
|
||||
@Controller('term')
|
||||
export class TermController {
|
||||
constructor(private readonly termService: TermService) {}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('get-tree-data')
|
||||
async getTreeData(@Query('tax-id') taxId: string) {
|
||||
try {
|
||||
const result = await this.termService.getTreeData({ taxonomyId: taxId });
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TermService } from './term.service';
|
||||
import { TermRouter } from './term.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { TermController } from './term.controller';
|
||||
import { RoleMapModule } from '../rbac/rbac.module';
|
||||
import { TermRowService } from './term.row.service';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule, RoleMapModule],
|
||||
providers: [TermService, TermRouter, TrpcService, TermRowService],
|
||||
exports: [TermService, TermRouter],
|
||||
controllers: [TermController],
|
||||
})
|
||||
export class TermModule { }
|
|
@ -0,0 +1,83 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { TermService } from './term.service'; // Adjust the import path as necessary
|
||||
import { Prisma, TermMethodSchema, UpdateOrderSchema } from '@nice/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { TermRowService } from './term.row.service';
|
||||
const TermCreateArgsSchema: ZodType<Prisma.TermCreateArgs> = z.any();
|
||||
const TermUpdateArgsSchema: ZodType<Prisma.TermUpdateArgs> = z.any();
|
||||
const TermFindFirstArgsSchema: ZodType<Prisma.TermFindFirstArgs> = z.any();
|
||||
const TermFindManyArgsSchema: ZodType<Prisma.TermFindManyArgs> = z.any();
|
||||
@Injectable()
|
||||
export class TermRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly termService: TermService,
|
||||
private readonly termRowService: TermRowService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(TermCreateArgsSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return this.termService.create(input, { staff });
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(TermUpdateArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.termService.update(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(TermFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.termService.findMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(TermFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.termService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.termService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.termService.updateOrder(input);
|
||||
}),
|
||||
upsertTags: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
tags: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return this.termService.upsertTags(staff, input.tags);
|
||||
}),
|
||||
getChildSimpleTree: this.trpc.procedure
|
||||
.input(TermMethodSchema.getSimpleTree)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.termService.getChildSimpleTree(staff, input);
|
||||
}),
|
||||
getParentSimpleTree: this.trpc.procedure
|
||||
.input(TermMethodSchema.getSimpleTree)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.termService.getParentSimpleTree(staff, input);
|
||||
}),
|
||||
getTreeData: this.trpc.protectProcedure
|
||||
.input(TermMethodSchema.getTreeData)
|
||||
.query(async ({ input }) => {
|
||||
return await this.termService.getTreeData(input);
|
||||
}),
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(TermMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.termRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ObjectType,
|
||||
RowModelRequest,
|
||||
TermMethodSchema,
|
||||
UserProfile,
|
||||
} from '@nice/common';
|
||||
import { date, z } from 'zod';
|
||||
import { RowCacheService } from '../base/row-cache.service';
|
||||
import { isFieldCondition } from '../base/sql-builder';
|
||||
|
||||
@Injectable()
|
||||
export class TermRowService extends RowCacheService {
|
||||
constructor() {
|
||||
super(ObjectType.TERM, false);
|
||||
}
|
||||
createUnGroupingRowSelect(
|
||||
requset: z.infer<typeof TermMethodSchema.getRows>,
|
||||
): string[] {
|
||||
const result = super
|
||||
.createUnGroupingRowSelect(requset)
|
||||
.concat([
|
||||
`${this.tableName}.name AS name`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.has_children AS has_children`,
|
||||
`${this.tableName}.parent_id AS parent_id`,
|
||||
`${this.tableName}.domain_id AS domain_id`,
|
||||
`taxonomy.name AS taxonomy_name`,
|
||||
`taxonomy.id AS taxonomy_id`,
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id`,
|
||||
];
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof TermMethodSchema.getRows>,
|
||||
staff: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
const { parentId, domainId, includeDeleted = false, taxonomyId } = request;
|
||||
if (isFieldCondition(condition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.groupKeys.length === 0) {
|
||||
if (parentId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
value: parentId,
|
||||
op: 'equals',
|
||||
});
|
||||
} else if (parentId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (domainId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
value: domainId,
|
||||
op: 'equals',
|
||||
});
|
||||
} else if (domainId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
if (taxonomyId) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.taxonomy_id`,
|
||||
value: taxonomyId,
|
||||
op: 'equals',
|
||||
});
|
||||
}
|
||||
if (!includeDeleted) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
type: 'date',
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,425 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
TermMethodSchema,
|
||||
db,
|
||||
Staff,
|
||||
Term,
|
||||
Prisma,
|
||||
TermDto,
|
||||
TreeDataNode,
|
||||
UserProfile,
|
||||
getUniqueItems,
|
||||
RolePerms,
|
||||
TaxonomySlug,
|
||||
ObjectType,
|
||||
TermAncestry,
|
||||
} from '@nice/common';
|
||||
import { z } from 'zod';
|
||||
import { BaseTreeService } from '../base/base.tree.service';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
import { formatToTermTreeData, mapToTermSimpleTree } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class TermService extends BaseTreeService<Prisma.TermDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.TERM, 'termAncestry', true);
|
||||
}
|
||||
|
||||
async create(args: Prisma.TermCreateArgs, params?: { staff?: UserProfile }) {
|
||||
args.data.createdBy = params?.staff?.id;
|
||||
const result = await super.create(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
async update(args: Prisma.TermUpdateArgs) {
|
||||
const result = await super.update(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除现有单位并清理DeptAncestry关系。
|
||||
* @param data - 用于删除现有单位的数据。
|
||||
* @returns 删除的单位对象。
|
||||
*/
|
||||
async softDeleteByIds(ids: string[]) {
|
||||
const descendantIds = await this.getDescendantIds(ids, true);
|
||||
const result = await super.softDeleteByIds(descendantIds);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.DELETED,
|
||||
data: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// async query(data: z.infer<typeof TermMethodSchema.findManyWithCursor>) {
|
||||
// const { limit = 10, initialIds, taxonomyId, taxonomySlug } = data;
|
||||
// // Fetch additional objects excluding initialIds
|
||||
// const ids =
|
||||
// typeof initialIds === 'string' ? [initialIds] : initialIds || [];
|
||||
// const initialTerms = await db.term.findMany({
|
||||
// where: {
|
||||
// id: {
|
||||
// in: ids,
|
||||
// },
|
||||
// },
|
||||
// include: {
|
||||
// domain: true,
|
||||
// children: true,
|
||||
// },
|
||||
// });
|
||||
// const terms = await db.term.findMany({
|
||||
// where: {
|
||||
// taxonomyId,
|
||||
// taxonomy: taxonomySlug && { slug: taxonomySlug },
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// take: limit !== -1 ? limit! : undefined,
|
||||
// include: {
|
||||
// domain: true,
|
||||
// taxonomy: true,
|
||||
// },
|
||||
// orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
|
||||
// });
|
||||
|
||||
// const results = getUniqueItems(
|
||||
// [...initialTerms, ...terms].filter(Boolean),
|
||||
// 'id',
|
||||
// );
|
||||
// return results;
|
||||
// }
|
||||
// /**
|
||||
|
||||
async upsertTags(staff: UserProfile, tags: string[]) {
|
||||
const tagTax = await db.taxonomy.findFirst({
|
||||
where: {
|
||||
slug: TaxonomySlug.TAG,
|
||||
},
|
||||
});
|
||||
// 批量查找所有存在的标签
|
||||
const existingTerms = await db.term.findMany({
|
||||
where: {
|
||||
name: {
|
||||
in: tags,
|
||||
},
|
||||
taxonomyId: tagTax.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 找出不存在的标签
|
||||
const existingTagNames = new Set(existingTerms.map((term) => term.name));
|
||||
const newTags = tags.filter((tag) => !existingTagNames.has(tag));
|
||||
// 批量创建不存在的标签
|
||||
const newTerms = await Promise.all(
|
||||
newTags.map((tag) =>
|
||||
this.create({
|
||||
data: {
|
||||
name: tag,
|
||||
taxonomyId: tagTax.id,
|
||||
domainId: staff.domainId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 合并现有标签和新创建的标签
|
||||
return [...existingTerms, ...newTerms];
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 查找多个术语并生成TermDto对象。
|
||||
// *
|
||||
// * @param staff 当前操作的工作人员
|
||||
// * @param ids 术语ID
|
||||
// * @returns 包含详细信息的术语对象
|
||||
// */
|
||||
// async findByIds(ids: string[], staff?: UserProfile) {
|
||||
// const terms = await db.term.findMany({
|
||||
// where: {
|
||||
// id: {
|
||||
// in: ids,
|
||||
// },
|
||||
// },
|
||||
// include: {
|
||||
// domain: true,
|
||||
// children: true,
|
||||
// },
|
||||
// });
|
||||
// return await Promise.all(
|
||||
// terms.map(async (term) => {
|
||||
// return await this.transformDto(term, staff);
|
||||
// }),
|
||||
// );
|
||||
// }
|
||||
// /**
|
||||
// * 获取指定条件下的术语子节点。
|
||||
// *
|
||||
// * @param staff 当前操作的工作人员
|
||||
// * @param data 查询条件
|
||||
// * @returns 子节点术语列表
|
||||
// */
|
||||
// async getChildren(
|
||||
// staff: UserProfile,
|
||||
// data: z.infer<typeof TermMethodSchema.getChildren>,
|
||||
// ) {
|
||||
// const { parentId, domainId, taxonomyId, cursor, limit = 10 } = data;
|
||||
// let queryCondition: Prisma.TermWhereInput = {
|
||||
// taxonomyId,
|
||||
// parentId: parentId,
|
||||
// OR: [{ domainId: null }],
|
||||
// deletedAt: null,
|
||||
// };
|
||||
// if (
|
||||
// staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) ||
|
||||
// staff?.permissions?.includes(RolePerms.READ_ANY_TERM)
|
||||
// ) {
|
||||
// queryCondition.OR = undefined;
|
||||
// } else {
|
||||
// queryCondition.OR = queryCondition.OR.concat([
|
||||
// { domainId: staff?.domainId },
|
||||
// { domainId: null },
|
||||
// ]);
|
||||
// }
|
||||
// const terms = await db.term.findMany({
|
||||
// where: queryCondition,
|
||||
// include: {
|
||||
// children: {
|
||||
// where: {
|
||||
// deletedAt: null,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// take: limit + 1,
|
||||
// cursor: cursor
|
||||
// ? { createdAt: cursor.split('_')[0], id: cursor.split('_')[1] }
|
||||
// : undefined,
|
||||
// });
|
||||
// let nextCursor: typeof cursor | undefined = undefined;
|
||||
// if (terms.length > limit) {
|
||||
// const nextItem = terms.pop();
|
||||
// nextCursor = `${nextItem.createdAt.toISOString()}_${nextItem!.id}`;
|
||||
// }
|
||||
// const termDtos = await Promise.all(
|
||||
// terms.map((item) => this.transformDto(item, staff)),
|
||||
// );
|
||||
// return {
|
||||
// items: termDtos,
|
||||
// nextCursor,
|
||||
// };
|
||||
// }
|
||||
|
||||
async getTreeData(data: z.infer<typeof TermMethodSchema.getTreeData>) {
|
||||
const { taxonomyId, taxonomySlug, domainId } = data;
|
||||
let terms = [];
|
||||
if (taxonomyId) {
|
||||
terms = await db.term.findMany({
|
||||
where: { taxonomyId, domainId, deletedAt: null },
|
||||
include: { children: true },
|
||||
orderBy: [{ order: 'asc' }],
|
||||
});
|
||||
} else if (taxonomySlug) {
|
||||
terms = await db.term.findMany({
|
||||
where: {
|
||||
taxonomy: {
|
||||
slug: taxonomySlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
domainId,
|
||||
},
|
||||
include: { children: true },
|
||||
orderBy: [{ order: 'asc' }],
|
||||
});
|
||||
}
|
||||
// Map to store terms by id for quick lookup
|
||||
const termMap = new Map<string, any>();
|
||||
terms.forEach((term) =>
|
||||
termMap.set(term.id, {
|
||||
...term,
|
||||
children: [],
|
||||
key: term.id,
|
||||
value: term.id,
|
||||
title: term.name,
|
||||
isLeaf: true, // Initialize as true, will update later if it has children
|
||||
}),
|
||||
);
|
||||
// Root nodes collection
|
||||
const roots = [];
|
||||
// Build the tree structure iteratively
|
||||
terms.forEach((term) => {
|
||||
if (term.parentId) {
|
||||
const parent = termMap.get(term.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(termMap.get(term.id));
|
||||
parent.isLeaf = false; // Update parent's isLeaf field
|
||||
}
|
||||
} else {
|
||||
roots.push(termMap.get(term.id));
|
||||
}
|
||||
});
|
||||
return roots as TreeDataNode[];
|
||||
}
|
||||
|
||||
async getChildSimpleTree(
|
||||
staff: UserProfile,
|
||||
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
const { domainId = null, permissions } = staff;
|
||||
const hasAnyPerms =
|
||||
staff?.permissions?.includes(RolePerms.MANAGE_ANY_TERM) ||
|
||||
staff?.permissions?.includes(RolePerms.READ_ANY_TERM);
|
||||
const { termIds, parentId, taxonomyId } = data;
|
||||
// 提取非空 deptIds
|
||||
const validTermIds = termIds?.filter((id) => id !== null) ?? [];
|
||||
const hasNullTermId = termIds?.includes(null) ?? false;
|
||||
|
||||
const [childrenData, selfData] = await Promise.all([
|
||||
db.termAncestry.findMany({
|
||||
where: {
|
||||
...(termIds && {
|
||||
OR: [
|
||||
...(validTermIds.length
|
||||
? [{ ancestorId: { in: validTermIds } }]
|
||||
: []),
|
||||
...(hasNullTermId ? [{ ancestorId: null }] : []),
|
||||
],
|
||||
}),
|
||||
descendant: {
|
||||
taxonomyId: taxonomyId,
|
||||
// 动态权限控制条件
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
ancestorId: parentId,
|
||||
relDepth: 1,
|
||||
},
|
||||
include: {
|
||||
descendant: { include: { children: true } },
|
||||
},
|
||||
orderBy: { descendant: { order: 'asc' } },
|
||||
}),
|
||||
termIds
|
||||
? db.term.findMany({
|
||||
where: {
|
||||
...(termIds && {
|
||||
OR: [
|
||||
...(validTermIds.length
|
||||
? [{ id: { in: validTermIds } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
taxonomyId: taxonomyId,
|
||||
// 动态权限控制条件
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: { children: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const children = childrenData
|
||||
.map(({ descendant }) => descendant)
|
||||
.filter(Boolean)
|
||||
.map(formatToTermTreeData);
|
||||
const selfItems = selfData.map(formatToTermTreeData);
|
||||
return getUniqueItems([...children, ...selfItems], 'id');
|
||||
}
|
||||
|
||||
async getParentSimpleTree(
|
||||
staff: UserProfile,
|
||||
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
const { domainId = null, permissions } = staff;
|
||||
const hasAnyPerms =
|
||||
permissions.includes(RolePerms.READ_ANY_TERM) ||
|
||||
permissions.includes(RolePerms.MANAGE_ANY_TERM);
|
||||
// 解构输入参数
|
||||
const { termIds, taxonomyId } = data;
|
||||
|
||||
// 并行查询父级部门ancestry和自身部门数据
|
||||
// 使用Promise.all提高查询效率,减少等待时间
|
||||
const [parentData, selfData] = await Promise.all([
|
||||
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
|
||||
db.termAncestry.findMany({
|
||||
where: {
|
||||
descendantId: { in: termIds }, // 查询条件:descendant在给定的部门ID列表中
|
||||
ancestor: {
|
||||
taxonomyId: taxonomyId,
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
ancestor: {
|
||||
include: {
|
||||
children: true, // 包含子节点信息
|
||||
parent: true, // 包含父节点信息
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
|
||||
}),
|
||||
|
||||
// 查询自身部门数据
|
||||
db.term.findMany({
|
||||
where: {
|
||||
id: { in: termIds },
|
||||
taxonomyId: taxonomyId,
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: { children: true }, // 包含子节点信息
|
||||
orderBy: { order: 'asc' }, // 按顺序升序排序
|
||||
}),
|
||||
]);
|
||||
|
||||
// 处理父级节点:过滤并映射为简单树结构
|
||||
const parents = parentData
|
||||
.map(({ ancestor }) => ancestor) // 提取祖先节点
|
||||
.filter((ancestor) => ancestor) // 过滤有效且超出根节点层级的节点
|
||||
.map(mapToTermSimpleTree); // 映射为简单树结构
|
||||
|
||||
// 处理自身节点:映射为简单树结构
|
||||
const selfItems = selfData.map(mapToTermSimpleTree);
|
||||
|
||||
// 合并并去重父级和自身节点,返回唯一项
|
||||
return getUniqueItems([...parents, ...selfItems], 'id');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { TreeDataNode } from '@nice/common';
|
||||
|
||||
export function formatToTermTreeData(term: any): TreeDataNode {
|
||||
return {
|
||||
id: term.id,
|
||||
key: term.id,
|
||||
value: term.id,
|
||||
title: term.name,
|
||||
order: term.order,
|
||||
pId: term.parentId,
|
||||
isLeaf: !Boolean(term.children?.length),
|
||||
};
|
||||
}
|
||||
export function mapToTermSimpleTree(term: any): TreeDataNode {
|
||||
return {
|
||||
id: term.id,
|
||||
key: term.id,
|
||||
value: term.id,
|
||||
title: term.name,
|
||||
order: term.order,
|
||||
pId: term.parentId,
|
||||
isLeaf: !Boolean(term.children?.length),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TransformRouter } from './transform.router';
|
||||
import { TransformService } from './transform.service';
|
||||
import { TermModule } from '@server/models/term/term.module';
|
||||
import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { StaffModule } from '../staff/staff.module';
|
||||
// import { TransformController } from './transform.controller';
|
||||
@Module({
|
||||
imports: [
|
||||
DepartmentModule,
|
||||
StaffModule,
|
||||
TermModule,
|
||||
TaxonomyModule,
|
||||
],
|
||||
providers: [TransformService, TransformRouter, TrpcService],
|
||||
exports: [TransformRouter, TransformService],
|
||||
// controllers:[TransformController]
|
||||
})
|
||||
export class TransformModule {}
|
|
@ -0,0 +1,31 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TransformService } from './transform.service';
|
||||
import { TransformMethodSchema } from '@nice/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
@Injectable()
|
||||
export class TransformRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly transformService: TransformService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
importTerms: this.trpc.protectProcedure
|
||||
.input(TransformMethodSchema.importTerms) // expect input according to the schema
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return this.transformService.importTerms(staff, input);
|
||||
}),
|
||||
importDepts: this.trpc.protectProcedure
|
||||
.input(TransformMethodSchema.importDepts) // expect input according to the schema
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return this.transformService.importDepts(staff, input);
|
||||
}),
|
||||
importStaffs: this.trpc.protectProcedure
|
||||
.input(TransformMethodSchema.importStaffs) // expect input according to the schema
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return this.transformService.importStaffs(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,541 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { TransformMethodSchema, db, Prisma, Staff } from '@nice/common';
|
||||
import dayjs from 'dayjs';
|
||||
import * as argon2 from 'argon2';
|
||||
import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service';
|
||||
import { uploadFile } from '@server/utils/tool';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { StaffService } from '../staff/staff.service';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||
|
||||
class TreeNode {
|
||||
value: string;
|
||||
children: TreeNode[];
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value;
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
addChild(childValue: string): TreeNode {
|
||||
let newChild = undefined;
|
||||
if (this.children.findIndex((child) => child.value === childValue) === -1) {
|
||||
newChild = new TreeNode(childValue);
|
||||
this.children.push(newChild);
|
||||
}
|
||||
return this.children.find((child) => child.value === childValue);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly taxonomyService: TaxonomyService,
|
||||
) {}
|
||||
private readonly logger = new Logger(TransformService.name);
|
||||
|
||||
excelDateToISO(excelDate: number) {
|
||||
// 设置 Excel 序列号的起点
|
||||
const startDate = dayjs('1899-12-31');
|
||||
// 加上 Excel 中的天数(注意必须减去2,因为 Excel 错误地把1900年当作闰年)
|
||||
const date = startDate.add(excelDate, 'day');
|
||||
// 转换为 ISO 字符串
|
||||
return date.toDate();
|
||||
}
|
||||
async getDepts(domainId: string, cellStr: string) {
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const depts: string[] = [];
|
||||
if (pattern.test(cellStr)) {
|
||||
const deptNames = cellStr.split(pattern);
|
||||
for (const name of deptNames) {
|
||||
const dept = await this.departmentService.findInDomain(domainId, name);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
} else {
|
||||
const dept = await this.departmentService.findInDomain(domainId, cellStr);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
|
||||
if (depts.length === 0) {
|
||||
this.logger.error(`未找到单位:${cellStr}`);
|
||||
}
|
||||
return depts;
|
||||
}
|
||||
async getStaffs(deptIds: string[], cellStr: string) {
|
||||
const staffs: string[] = [];
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const allStaffsArrays = await Promise.all(
|
||||
deptIds.map((deptId) => this.staffService.findByDept({ deptId })),
|
||||
);
|
||||
const combinedStaffs = allStaffsArrays.reduce(
|
||||
(acc, curr) => acc.concat(curr),
|
||||
[],
|
||||
);
|
||||
if (pattern.test(cellStr)) {
|
||||
const staffNames = cellStr.split(pattern);
|
||||
|
||||
for (const name of staffNames) {
|
||||
if (
|
||||
combinedStaffs.map((staff, index) => staff?.showname).includes(name)
|
||||
) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff?.showname === name,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
}
|
||||
} else {
|
||||
// const staff = await this.lanxin.getStaffsByDepartment(deptIds);
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
if (
|
||||
combinedStaffs.map((staff, index) => staff?.showname).includes(cellStr)
|
||||
) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff?.showname === cellStr,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (staffs.length === 0) {
|
||||
this.logger.error(`未找到人员:${cellStr}`);
|
||||
}
|
||||
return staffs;
|
||||
}
|
||||
|
||||
buildTree(data: string[][]): TreeNode {
|
||||
const root = new TreeNode('root');
|
||||
try {
|
||||
for (const path of data) {
|
||||
let currentNode = root;
|
||||
for (const value of path) {
|
||||
currentNode = currentNode.addChild(value);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
async generateTreeFromFile(file: Buffer): Promise<{ tree: TreeNode }> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file);
|
||||
const worksheet = workbook.getWorksheet(1);
|
||||
|
||||
const data: string[][] = [];
|
||||
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) {
|
||||
// Skip header row if any
|
||||
try {
|
||||
const rowData: string[] = (row.values as string[])
|
||||
.slice(2)
|
||||
.map((cell) => (cell || '').toString());
|
||||
data.push(rowData.map((value) => value.trim()));
|
||||
} catch (error) {
|
||||
throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fill forward values
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
for (let j = 0; j < data[i].length; j++) {
|
||||
if (!data[i][j]) data[i][j] = data[i - 1][j];
|
||||
}
|
||||
}
|
||||
return { tree: this.buildTree(data) };
|
||||
}
|
||||
printTree(node: TreeNode, level: number = 0): void {
|
||||
const indent = ' '.repeat(level);
|
||||
|
||||
for (const child of node.children) {
|
||||
this.printTree(child, level + 1);
|
||||
}
|
||||
}
|
||||
swapKeyValue<T extends Record<string, string>>(
|
||||
input: T,
|
||||
): { [K in T[keyof T]]: Extract<keyof T, string> } {
|
||||
const result: Partial<{ [K in T[keyof T]]: Extract<keyof T, string> }> = {};
|
||||
for (const key in input) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
const value = input[key];
|
||||
result[value] = key;
|
||||
}
|
||||
}
|
||||
return result as { [K in T[keyof T]]: Extract<keyof T, string> };
|
||||
}
|
||||
isEmptyRow(row: any) {
|
||||
return row.every((cell: any) => {
|
||||
return !cell || cell.toString().trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
async importStaffs(data: z.infer<typeof TransformMethodSchema.importStaffs>) {
|
||||
const { base64, domainId } = data;
|
||||
this.logger.log('开始');
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const importsStaffMethodSchema = z.object({
|
||||
name: z.string(),
|
||||
phoneNumber: z.string().regex(/^\d+$/), // Assuming phone numbers should be numeric
|
||||
deptName: z.string(),
|
||||
});
|
||||
const worksheet = workbook.getWorksheet(1); // Assuming the data is in the first sheet
|
||||
const staffs: { name: string; phoneNumber: string; deptName: string }[] =
|
||||
[];
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) {
|
||||
// Assuming the first row is headers
|
||||
const name = row.getCell(1).value as string;
|
||||
const phoneNumber = row.getCell(2).value.toString() as string;
|
||||
const deptName = row.getCell(3).value as string;
|
||||
try {
|
||||
importsStaffMethodSchema.parse({ name, phoneNumber, deptName });
|
||||
staffs.push({ name, phoneNumber, deptName });
|
||||
} catch (error) {
|
||||
throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 获取所有唯一的部门名称
|
||||
const uniqueDeptNames = [...new Set(staffs.map((staff) => staff.deptName))];
|
||||
// 获取所有部门名称对应的部门ID
|
||||
const deptIdsMap = await this.departmentService.getDeptIdsByNames(
|
||||
uniqueDeptNames,
|
||||
domainId,
|
||||
);
|
||||
const count = await db.staff.count();
|
||||
const hashedPassword = await argon2.hash('123456');
|
||||
|
||||
// 为员工数据添加部门ID
|
||||
const staffsToCreate = staffs.map((staff, index) => ({
|
||||
showname: staff.name,
|
||||
username: staff.phoneNumber,
|
||||
phoneNumber: staff.phoneNumber,
|
||||
password: hashedPassword,
|
||||
deptId: deptIdsMap[staff.deptName],
|
||||
domainId,
|
||||
order: index + count,
|
||||
}));
|
||||
// 批量创建员工数据
|
||||
const createdStaffs = await db.staff.createMany({
|
||||
data: staffsToCreate,
|
||||
});
|
||||
await deleteByPattern('row-*');
|
||||
return createdStaffs;
|
||||
}
|
||||
async importTerms(
|
||||
staff: Staff,
|
||||
data: z.infer<typeof TransformMethodSchema.importTerms>,
|
||||
) {
|
||||
const { base64, domainId, taxonomyId, parentId } = data;
|
||||
this.logger.log('开始');
|
||||
await db.$transaction(async (tx) => {
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const { tree: root } = await this.generateTreeFromFile(buffer);
|
||||
|
||||
this.printTree(root);
|
||||
|
||||
const termsData: Prisma.TermCreateManyInput[] = [];
|
||||
const termAncestriesData: Prisma.TermAncestryCreateManyInput[] = [];
|
||||
if (!taxonomyId) {
|
||||
throw new Error('未指定分类!');
|
||||
}
|
||||
this.logger.log('存在taxonomyId');
|
||||
const taxonomy = await tx.taxonomy.findUnique({
|
||||
where: { id: taxonomyId },
|
||||
});
|
||||
if (!taxonomy) {
|
||||
throw new Error('未找到对应分类');
|
||||
}
|
||||
const count = await tx.term.count({ where: { taxonomyId: taxonomyId } });
|
||||
let termIndex = 0;
|
||||
this.logger.log(count);
|
||||
|
||||
const gatherTermsData = async (nodes: TreeNode[], depth = 0) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const termData = {
|
||||
name: node.value,
|
||||
taxonomyId: taxonomyId,
|
||||
domainId: domainId,
|
||||
createdBy: staff.id,
|
||||
order: count + termIndex + 1,
|
||||
};
|
||||
termsData.push(termData);
|
||||
termIndex++;
|
||||
// Debug: Log term data preparation
|
||||
|
||||
await gatherTermsData(node.children, depth + 1);
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
await gatherTermsData(root.children);
|
||||
let createdTerms: { id: string; name: string }[] = [];
|
||||
try {
|
||||
createdTerms = await tx.term.createManyAndReturn({
|
||||
data: termsData,
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
// Debug: Log created terms
|
||||
} catch (error) {
|
||||
console.error('创建Terms报错:', error);
|
||||
throw new Error('创建失败');
|
||||
}
|
||||
const termsUpdate = [];
|
||||
|
||||
const gatherAncestryData = (
|
||||
nodes: TreeNode[],
|
||||
ancestors: string[] = parentId ? [null, parentId] : [null],
|
||||
depth = 0,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
const dept = createdTerms.find((dept) => dept.name === node.value);
|
||||
if (dept) {
|
||||
termsUpdate.push({
|
||||
where: { id: dept.id },
|
||||
data: { parentId: ancestors[ancestors.length - 1] },
|
||||
});
|
||||
for (let i = 0; i < ancestors.length; i++) {
|
||||
const ancestryData = {
|
||||
ancestorId: ancestors[i],
|
||||
descendantId: dept.id,
|
||||
relDepth: depth - i + 1,
|
||||
};
|
||||
termAncestriesData.push(ancestryData);
|
||||
}
|
||||
const newAncestors = [...ancestors, dept.id];
|
||||
gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
}
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
// console.log(`depth:${depth}`);
|
||||
// for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
// const term = createdTerms.find((term) => term.name === node.value);
|
||||
// if (term) {
|
||||
// termsUpdate.push({
|
||||
// where: { id: term.id },
|
||||
// data: { parentId: ancestors[ancestors.length - 1] },
|
||||
// });
|
||||
// for (let i = 0; i < ancestors.length; i++) {
|
||||
// const ancestryData = {
|
||||
// ancestorId: ancestors[i],
|
||||
// descendantId: term.id,
|
||||
// relDepth: depth - i,
|
||||
// };
|
||||
// termAncestriesData.push(ancestryData);
|
||||
// console.log(`准备好的闭包表数据ATermAncestryData:`, ancestryData);
|
||||
// }
|
||||
// const newAncestors = [...ancestors, term.id];
|
||||
// gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
// }
|
||||
// } else {
|
||||
// gatherAncestryData(
|
||||
// node.children,
|
||||
// [createdTerms.find((term) => term.name === node.value).id],
|
||||
// depth + 1,
|
||||
// );
|
||||
// }
|
||||
// currentIndex++;
|
||||
// }
|
||||
};
|
||||
gatherAncestryData(root.children);
|
||||
|
||||
this.logger.log('准备好闭包表数据 Ancestries Data:', termAncestriesData);
|
||||
try {
|
||||
const updatePromises = termsUpdate.map((update) =>
|
||||
tx.term.update(update),
|
||||
);
|
||||
await Promise.all(updatePromises);
|
||||
await tx.termAncestry.createMany({ data: termAncestriesData });
|
||||
const allTerm = await tx.term.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdTerms.map((termt) => termt.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, deletedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const term of allTerm) {
|
||||
await tx.term.update({
|
||||
where: {
|
||||
id: term.id,
|
||||
},
|
||||
data: {
|
||||
hasChildren: term.children.length > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await deleteByPattern('row-*');
|
||||
return { count: createdTerms.length };
|
||||
} catch (error) {
|
||||
console.error('Error 更新Term或者创建Terms闭包表失败:', error);
|
||||
throw new Error('更新术语信息或者创建术语闭包表失败');
|
||||
}
|
||||
});
|
||||
//prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值
|
||||
const termAncestries = await db.termAncestry.findMany({
|
||||
include: {
|
||||
ancestor: true,
|
||||
descendant: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async importDepts(
|
||||
staff: Staff,
|
||||
data: z.infer<typeof TransformMethodSchema.importDepts>,
|
||||
) {
|
||||
const { base64, domainId, parentId } = data;
|
||||
|
||||
// this.logger.log('开始', parentId);
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
const { tree: root } = await this.generateTreeFromFile(buffer);
|
||||
|
||||
this.printTree(root);
|
||||
|
||||
const deptsData: Prisma.DepartmentCreateManyInput[] = [];
|
||||
const deptAncestriesData: Prisma.DeptAncestryCreateManyInput[] = [];
|
||||
const count = await tx.department.count({ where: {} });
|
||||
let deptIndex = 0;
|
||||
// this.logger.log(count);
|
||||
const gatherDeptsData = async (
|
||||
nodes: TreeNode[],
|
||||
depth = 0,
|
||||
dept?: string,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
for (const node of nodes) {
|
||||
const deptData = {
|
||||
name: node.value,
|
||||
// taxonomyId: taxonomyId,
|
||||
domainId: domainId,
|
||||
// createdBy: staff.id,
|
||||
|
||||
order: count + deptIndex + 1,
|
||||
};
|
||||
deptsData.push(deptData);
|
||||
deptIndex++;
|
||||
// Debug: Log term data preparation
|
||||
|
||||
await gatherDeptsData(node.children, depth + 1);
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
await gatherDeptsData(root.children);
|
||||
let createdDepts: { id: string; name: string }[] = [];
|
||||
try {
|
||||
createdDepts = await tx.department.createManyAndReturn({
|
||||
data: deptsData,
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
// Debug: Log created terms
|
||||
} catch (error) {
|
||||
console.error('创建Depts报错:', error);
|
||||
throw new Error('创建失败');
|
||||
}
|
||||
const deptsUpdate = [];
|
||||
const gatherAncestryData = (
|
||||
nodes: TreeNode[],
|
||||
ancestors: string[] = parentId ? [null, parentId] : [null],
|
||||
depth = 0,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
const dept = createdDepts.find((dept) => dept.name === node.value);
|
||||
if (dept) {
|
||||
deptsUpdate.push({
|
||||
where: { id: dept.id },
|
||||
data: { parentId: ancestors[ancestors.length - 1] },
|
||||
});
|
||||
|
||||
for (let i = 0; i < ancestors.length; i++) {
|
||||
const ancestryData = {
|
||||
ancestorId: ancestors[i],
|
||||
descendantId: dept.id,
|
||||
relDepth: depth - i + 1,
|
||||
};
|
||||
deptAncestriesData.push(ancestryData);
|
||||
}
|
||||
const newAncestors = [...ancestors, dept.id];
|
||||
gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
gatherAncestryData(root?.children);
|
||||
|
||||
this.logger.log('准备好闭包表数据 Ancestries Data:', deptAncestriesData);
|
||||
try {
|
||||
const updatePromises = deptsUpdate.map((update) =>
|
||||
tx.department.update(update),
|
||||
);
|
||||
await Promise.all(updatePromises);
|
||||
await tx.deptAncestry.createMany({ data: deptAncestriesData });
|
||||
const allDept = await tx.department.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdDepts.map((dept) => dept.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, deletedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const dept of allDept) {
|
||||
await tx.department.update({
|
||||
where: {
|
||||
id: dept.id,
|
||||
},
|
||||
data: {
|
||||
hasChildren: dept.children.length > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await deleteByPattern('row-*');
|
||||
return { count: createdDepts.length };
|
||||
} catch (error) {
|
||||
console.error('Error 更新Dept或者创建Depts闭包表失败:', error);
|
||||
throw new Error('更新单位信息或者创建单位闭包表失败');
|
||||
}
|
||||
});
|
||||
//prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值
|
||||
// const deptAncestries = db.deptAncestry.findMany({
|
||||
// include: {
|
||||
// ancestor: true,
|
||||
// descendant: true,
|
||||
// },
|
||||
// });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { VisitService } from './visit.service';
|
||||
import { VisitRouter } from './visit.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
@Module({
|
||||
providers: [VisitService, VisitRouter, TrpcService],
|
||||
exports: [VisitRouter]
|
||||
})
|
||||
export class VisitModule { }
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { Prisma } from '@nice/common';
|
||||
|
||||
import { VisitService } from './visit.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
|
||||
@Injectable()
|
||||
export class VisitRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly visitService: VisitService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(VisitCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.visitService.create(input, staff);
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.visitService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(VisitDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.visitService.deleteMany(input);
|
||||
}),
|
||||
|
||||
|
||||
});
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { UserProfile, db, ObjectType, Prisma, VisitType } from '@nice/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
@Injectable()
|
||||
export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.VISIT);
|
||||
}
|
||||
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
||||
const { postId, lectureId, messageId } = args.data;
|
||||
const visitorId = args.data.visitorId || staff?.id;
|
||||
let result;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
type: args.data.type,
|
||||
visitorId,
|
||||
OR: [{ postId }, { lectureId }, { messageId }],
|
||||
},
|
||||
});
|
||||
if (!existingVisit) {
|
||||
result = await super.create(args);
|
||||
} else if (args.data.type === VisitType.READED) {
|
||||
result = await super.update({
|
||||
where: { id: existingVisit.id },
|
||||
data: {
|
||||
...args.data,
|
||||
views: existingVisit.views + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// if (troubleId && args.data.type === VisitType.READED) {
|
||||
// EventBus.emit('updateViewCount', {
|
||||
// objectType: ObjectType.TROUBLE,
|
||||
// id: troubleId,
|
||||
// });
|
||||
// }
|
||||
return result;
|
||||
}
|
||||
async createMany(args: Prisma.VisitCreateManyArgs, staff?: UserProfile) {
|
||||
const data = Array.isArray(args.data) ? args.data : [args.data];
|
||||
const updatePromises: any[] = [];
|
||||
const createData: Prisma.VisitCreateManyInput[] = [];
|
||||
await Promise.all(
|
||||
data.map(async (item) => {
|
||||
if (staff && !item.visitorId) item.visitorId = staff.id;
|
||||
const { postId, lectureId, messageId, visitorId } = item;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
visitorId,
|
||||
OR: [{ postId }, { lectureId }, { messageId }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingVisit) {
|
||||
updatePromises.push(
|
||||
super.update({
|
||||
where: { id: existingVisit.id },
|
||||
data: {
|
||||
...item,
|
||||
views: existingVisit.views + 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
createData.push(item);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Execute all updates in parallel
|
||||
await Promise.all(updatePromises);
|
||||
// Create new visits for those not existing
|
||||
if (createData.length > 0) {
|
||||
return super.createMany({
|
||||
...args,
|
||||
data: createData,
|
||||
});
|
||||
}
|
||||
|
||||
return { count: updatePromises.length }; // Return the number of updates if no new creates
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ObjectType } from '@nice/common';
|
||||
import { QueueJobType } from '../types';
|
||||
@Injectable()
|
||||
export class PostProcessService {
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue) { }
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { join } from 'path';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(), // 导入 ConfigModule
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
port: configService.get<number>('REDIS_PORT', 6379),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BullModule.registerQueue(
|
||||
{
|
||||
name: 'general',
|
||||
processors: [join(__dirname, 'worker/processor.js')],
|
||||
},
|
||||
{
|
||||
name: 'file-queue', // 新增文件处理队列
|
||||
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
|
||||
},
|
||||
),
|
||||
],
|
||||
providers: [Logger],
|
||||
exports: [],
|
||||
})
|
||||
export class QueueModule {}
|
|
@ -0,0 +1,3 @@
|
|||
export enum QueueJobType {
|
||||
FILE_PROCESS = 'file_process',
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { QueueJobType } from '../types';
|
||||
import { ResourceProcessingPipeline } from '@server/models/resource/pipe/resource.pipeline';
|
||||
import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor';
|
||||
import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor';
|
||||
const logger = new Logger('FileProcessorWorker');
|
||||
const pipeline = new ResourceProcessingPipeline()
|
||||
.addProcessor(new ImageProcessor())
|
||||
.addProcessor(new VideoProcessor());
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
if (job.name === QueueJobType.FILE_PROCESS) {
|
||||
console.log('job', job);
|
||||
const { resource } = job.data;
|
||||
if (!resource) {
|
||||
throw new Error('No resource provided in job data');
|
||||
}
|
||||
const result = await pipeline.execute(resource);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ObjectType } from '@nice/common';
|
||||
// import {
|
||||
// updateCourseEnrollmentStats,
|
||||
// updateCourseReviewStats,
|
||||
// } from '@server/models/course/utils';
|
||||
import { QueueJobType } from '../types';
|
||||
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
|
||||
import { SocketMessage } from '@nice/common';
|
||||
|
||||
const DEFAULT_CONFIG: WebSocketServerConfig = {
|
||||
pingInterval: 30000,
|
||||
pingTimeout: 5000,
|
||||
debug: false, // 新增默认调试配置
|
||||
};
|
||||
interface IWebSocketServer {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
broadcast(data: any): void;
|
||||
handleConnection(ws: WSClient): void;
|
||||
handleDisconnection(ws: WSClient): void;
|
||||
}
|
||||
|
||||
export abstract class BaseWebSocketServer implements IWebSocketServer {
|
||||
private _wss: WebSocketServer | null = null;
|
||||
protected clients: Set<WSClient> = new Set();
|
||||
protected timeouts: Map<WSClient, NodeJS.Timeout> = new Map();
|
||||
protected pingIntervalId?: NodeJS.Timeout;
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
protected readonly finalConfig: WebSocketServerConfig;
|
||||
private userClientMap: Map<string, WSClient> = new Map();
|
||||
constructor(
|
||||
protected readonly config: Partial<WebSocketServerConfig> = {}
|
||||
) {
|
||||
this.finalConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
protected debugLog(message: string, ...optionalParams: any[]): void {
|
||||
if (this.finalConfig.debug) {
|
||||
this.logger.debug(message, ...optionalParams);
|
||||
}
|
||||
}
|
||||
public getClientCount() {
|
||||
return this.clients.size
|
||||
}
|
||||
// 暴露 WebSocketServer 实例的只读访问
|
||||
public get wss(): WebSocketServer | null {
|
||||
return this._wss;
|
||||
}
|
||||
|
||||
// 内部使用的 setter
|
||||
protected set wss(value: WebSocketServer | null) {
|
||||
this._wss = value;
|
||||
}
|
||||
|
||||
public abstract get serverType(): WebSocketType;
|
||||
|
||||
public get serverPath(): string {
|
||||
return this.finalConfig.path || `/${this.serverType}`;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this._wss) await this.stop();
|
||||
|
||||
this._wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
path: this.serverPath
|
||||
});
|
||||
|
||||
this.debugLog(`WebSocket server starting on path: ${this.serverPath}`);
|
||||
this.setupServerEvents();
|
||||
this.startPingInterval();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.pingIntervalId) {
|
||||
clearInterval(this.pingIntervalId);
|
||||
this.pingIntervalId = undefined;
|
||||
}
|
||||
|
||||
this.clients.forEach(client => client.close());
|
||||
this.clients.clear();
|
||||
this.timeouts.clear();
|
||||
|
||||
if (this._wss) {
|
||||
await new Promise(resolve => this._wss!.close(resolve));
|
||||
this._wss = null;
|
||||
}
|
||||
|
||||
this.debugLog(`WebSocket server stopped on path: ${this.serverPath}`);
|
||||
}
|
||||
|
||||
public broadcast(data: SocketMessage): void {
|
||||
this.clients.forEach(client =>
|
||||
client.readyState === WebSocket.OPEN && client.send(JSON.stringify(data))
|
||||
);
|
||||
}
|
||||
public sendToUser(id: string, data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
const client = this.userClientMap.get(id);
|
||||
client?.send(message)
|
||||
}
|
||||
public sendToUsers(ids: string[], data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
ids.forEach(id => {
|
||||
const client = this.userClientMap.get(id);
|
||||
client?.send(message);
|
||||
});
|
||||
}
|
||||
public sendToRoom(roomId: string, data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
this.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN && client.roomId === roomId) {
|
||||
client.send(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
protected getRoomClientsCount(roomId?: string): number {
|
||||
if (!roomId) return 0;
|
||||
return Array.from(this.clients).filter(client => client.roomId === roomId).length;
|
||||
}
|
||||
|
||||
public handleConnection(ws: WSClient): void {
|
||||
if (ws.userId) {
|
||||
this.userClientMap.set(ws.userId, ws);
|
||||
}
|
||||
ws.isAlive = true;
|
||||
ws.type = this.serverType;
|
||||
this.clients.add(ws);
|
||||
this.setupClientEvents(ws);
|
||||
|
||||
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||
this.debugLog(`
|
||||
[${this.serverType}] connected
|
||||
userId ${ws.userId}
|
||||
roomId ${ws.roomId}
|
||||
room clients ${roomClientsCount}
|
||||
total clients ${this.clients.size}`);
|
||||
}
|
||||
|
||||
public handleDisconnection(ws: WSClient): void {
|
||||
if (ws.userId) {
|
||||
this.userClientMap.delete(ws.userId);
|
||||
}
|
||||
this.clients.delete(ws);
|
||||
const timeout = this.timeouts.get(ws);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.timeouts.delete(ws);
|
||||
}
|
||||
ws.terminate();
|
||||
|
||||
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||
|
||||
this.debugLog(`
|
||||
[${this.serverType}] disconnected
|
||||
userId ${ws.userId}
|
||||
roomId ${ws.roomId}
|
||||
room clients ${roomClientsCount}
|
||||
total clients ${this.clients.size}`);
|
||||
}
|
||||
protected setupClientEvents(ws: WSClient): void {
|
||||
ws.on('pong', () => this.handlePong(ws))
|
||||
.on('close', () => this.handleDisconnection(ws))
|
||||
.on('error', (error) => {
|
||||
this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error);
|
||||
this.handleDisconnection(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private handlePong(ws: WSClient): void {
|
||||
ws.isAlive = true;
|
||||
const timeout = this.timeouts.get(ws);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.timeouts.delete(ws);
|
||||
}
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.pingIntervalId = setInterval(
|
||||
() => this.pingClients(),
|
||||
this.finalConfig.pingInterval
|
||||
);
|
||||
}
|
||||
|
||||
private pingClients(): void {
|
||||
this.clients.forEach(ws => {
|
||||
if (!ws.isAlive) return this.handleDisconnection(ws);
|
||||
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
const timeout = setTimeout(
|
||||
() => !ws.isAlive && this.handleDisconnection(ws),
|
||||
this.finalConfig.pingTimeout
|
||||
);
|
||||
this.timeouts.set(ws, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
protected setupServerEvents(): void {
|
||||
if (!this._wss) return;
|
||||
this._wss
|
||||
.on('connection', (ws: WSClient) => this.handleConnection(ws))
|
||||
.on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 此模块实现了一个回调处理系统,用于在协同编辑文档发生更改时通知外部服务。
|
||||
* 它支持多种共享数据类型(Array、Map、Text、XML等)的同步,并可以将更新通过HTTP POST请求发送到指定的回调URL。
|
||||
* 主要用于与外部系统集成,实现文档变更的实时通知。
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { parseInt as libParseInt } from 'lib0/number';
|
||||
import { WSSharedDoc } from './ws-shared-doc';
|
||||
|
||||
|
||||
/**
|
||||
* 回调URL配置,从环境变量中获取
|
||||
* 如果环境变量未设置则为null
|
||||
*/
|
||||
const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null;
|
||||
|
||||
/**
|
||||
* 回调超时时间配置,从环境变量中获取
|
||||
* 默认为5000毫秒
|
||||
*/
|
||||
const CALLBACK_TIMEOUT = libParseInt(process.env.CALLBACK_TIMEOUT || '5000');
|
||||
|
||||
/**
|
||||
* 需要监听变更的共享对象配置
|
||||
* 从环境变量CALLBACK_OBJECTS中解析JSON格式的配置
|
||||
*/
|
||||
const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {};
|
||||
|
||||
/**
|
||||
* 导出回调URL是否已配置的标志
|
||||
*/
|
||||
export const isCallbackSet = !!CALLBACK_URL;
|
||||
|
||||
/**
|
||||
* 定义要发送的数据结构接口
|
||||
*/
|
||||
interface DataToSend {
|
||||
room: string; // 房间/文档标识
|
||||
data: Record<string, {
|
||||
type: string; // 数据类型
|
||||
content: any; // 数据内容
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义更新数据的类型
|
||||
*/
|
||||
type UpdateType = Uint8Array;
|
||||
|
||||
/**
|
||||
* 定义更新来源的类型
|
||||
*/
|
||||
type OriginType = any;
|
||||
|
||||
/**
|
||||
* 处理文档更新的回调函数
|
||||
* @param update - 更新的数据
|
||||
* @param origin - 更新的来源
|
||||
* @param doc - 共享文档实例
|
||||
*/
|
||||
export const callbackHandler = (update: UpdateType, origin: OriginType, doc: WSSharedDoc): void => {
|
||||
// 获取文档名称作为房间标识
|
||||
const room = doc.name;
|
||||
|
||||
// 初始化要发送的数据对象
|
||||
const dataToSend: DataToSend = {
|
||||
room,
|
||||
data: {}
|
||||
};
|
||||
|
||||
// 获取所有需要监听的共享对象名称
|
||||
const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
|
||||
|
||||
// 遍历所有共享对象,获取它们的最新内容
|
||||
sharedObjectList.forEach(sharedObjectName => {
|
||||
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
|
||||
dataToSend.data[sharedObjectName] = {
|
||||
type: sharedObjectType,
|
||||
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON()
|
||||
};
|
||||
});
|
||||
|
||||
// 如果配置了回调URL,则发送HTTP请求
|
||||
if (CALLBACK_URL) {
|
||||
callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送HTTP回调请求
|
||||
* @param url - 回调的目标URL
|
||||
* @param timeout - 超时时间
|
||||
* @param data - 要发送的数据
|
||||
*/
|
||||
const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
|
||||
// 将数据转换为JSON字符串
|
||||
const dataString = JSON.stringify(data);
|
||||
|
||||
// 配置HTTP请求选项
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
timeout,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(dataString)
|
||||
}
|
||||
};
|
||||
|
||||
// 创建HTTP请求
|
||||
const req = http.request(options);
|
||||
|
||||
// 处理超时事件
|
||||
req.on('timeout', () => {
|
||||
console.warn('Callback request timed out.');
|
||||
req.abort();
|
||||
});
|
||||
|
||||
// 处理错误事件
|
||||
req.on('error', (e) => {
|
||||
console.error('Callback request error.', e);
|
||||
req.abort();
|
||||
});
|
||||
|
||||
// 发送数据
|
||||
req.write(dataString);
|
||||
req.end();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据对象类型获取共享对象的内容
|
||||
* @param objName - 对象名称
|
||||
* @param objType - 对象类型
|
||||
* @param doc - 共享文档实例
|
||||
* @returns 共享对象的内容
|
||||
*/
|
||||
const getContent = (objName: string, objType: string, doc: WSSharedDoc): any => {
|
||||
// 根据对象类型返回相应的共享对象
|
||||
switch (objType) {
|
||||
case 'Array': return doc.getArray(objName);
|
||||
case 'Map': return doc.getMap(objName);
|
||||
case 'Text': return doc.getText(objName);
|
||||
case 'XmlFragment': return doc.getXmlFragment(objName);
|
||||
case 'XmlElement': return doc.getXmlElement(objName);
|
||||
default: return {};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { YjsServer } from './yjs.server';
|
||||
|
||||
@Module({
|
||||
providers: [YjsServer],
|
||||
exports: [YjsServer]
|
||||
})
|
||||
export class CollaborationModule { }
|
|
@ -0,0 +1,34 @@
|
|||
import { LeveldbPersistence } from 'y-leveldb';
|
||||
import * as Y from 'yjs';
|
||||
import { WSSharedDoc } from './ws-shared-doc';
|
||||
const persistenceDir = process.env.YPERSISTENCE;
|
||||
interface Persistence {
|
||||
bindState: (docName: string, ydoc: WSSharedDoc) => void;
|
||||
writeState: (docName: string, ydoc: WSSharedDoc) => Promise<any>;
|
||||
provider: any;
|
||||
}
|
||||
let persistence: Persistence | null = null;
|
||||
|
||||
if (typeof persistenceDir === 'string') {
|
||||
console.info('Persisting documents to "' + persistenceDir + '"');
|
||||
const ldb = new LeveldbPersistence(persistenceDir);
|
||||
persistence = {
|
||||
provider: ldb,
|
||||
bindState: async (docName, ydoc) => {
|
||||
const persistedYdoc = await ldb.getYDoc(docName);
|
||||
const newUpdates = Y.encodeStateAsUpdate(ydoc);
|
||||
ldb.storeUpdate(docName, newUpdates);
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
|
||||
ydoc.on('update', (update: Uint8Array) => {
|
||||
ldb.storeUpdate(docName, update);
|
||||
});
|
||||
},
|
||||
writeState: async (_docName, _ydoc) => { },
|
||||
};
|
||||
}
|
||||
|
||||
export const setPersistence = (persistence_: Persistence | null) => {
|
||||
persistence = persistence_;
|
||||
};
|
||||
|
||||
export const getPersistence = (): Persistence | null => persistence;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue