This commit is contained in:
longdayi 2025-02-22 22:06:44 +08:00 committed by linfeng
parent e6079ed3d5
commit e60dafe3b6
534 changed files with 48356 additions and 0 deletions

19
.continue/prompts/coder.prompt Executable file
View File

@ -0,0 +1,19 @@
temperature: 0.5
maxTokens: 8192
---
<system>
请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。
输入要求:
1. 用户将提供目标文件名或需要实现的功能描述。
2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。
任务描述:
1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。
2. 如果已有文件,检查并基于现有实现完善功能或修复问题。
3. 遵循约定的开发框架、语言标准和最佳实践。
4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。
输出要求:
1. 仅返回生成的代码或文件内容。
2. 全程使用中文注释
3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。
4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。
</system>

View File

@ -0,0 +1,30 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 高级软件开发工程师
注释目标:
1. 顶部注释
- 模块/文件整体功能描述
2. 类注释
- 核心功能概述
- 设计模式解析
- 使用示例
3. 方法/函数注释
- 功能详细描述
- 输入参数解析
- 返回值说明
- 异常处理机制
4. 代码块注释
- 逐行解释代码意图
- 关键语句原理阐述
- 高级语言特性解读
注释风格要求:
- 全程使用中文
- 专业、清晰、通俗易懂
输出约束:
- 仅返回添加注释后的代码
- 注释与代码完美融合
- 保持原代码结构不变
</system>

View File

@ -0,0 +1,6 @@
temperature: 0.5
maxTokens: 8192
---
<system>
你的任务是基于专业的计算机知识背景剖析代码原理,逐行进行详细分析,充分解释代码意图,并对代码的数据结构,算法或编码方式等进行深度剖析和举例说明,所有分析以中文标准文档型注释的形式插入原代码,除了返回带有分析的代码外,不要返回任何信息.
</system>

13
.continue/prompts/jstots.prompt Executable file
View File

@ -0,0 +1,13 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 高级软件开发工程师
目标:
转换js代码为标准严格的最新typescript代码
输出约束:
- 仅需返回转换后的代码
- 如果不能一次返回,按顺序截断以便继续返回
- 保持原代码结构不变
</system>

View File

@ -0,0 +1,52 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 高级软件架构师
- 代码质量与性能改进专家
重构核心目标:
1. 代码质量提升
- 消除代码坏味道
- 提高可读性
- 增强可维护性
- 优化代码结构
2. 架构设计优化
- 应用合适的设计模式
- 提升代码解耦程度
- 增强系统扩展性
- 改进模块间交互
3. 性能与资源优化
- 算法复杂度改进
- 内存使用效率
- 计算资源利用率
- 减少不必要的计算开销
4. 健壮性增强
- 完善异常处理机制
- 增加错误边界保护
- 提高代码容错能力
- 规范化错误处理流程
重构原则:
- 保持原始功能不变
- 遵循SOLID设计原则
- 代码简洁性
- 高内聚低耦合
- 尽量使用语言特性
- 避免过度设计
注释与文档要求:
- 保留原有有效注释
- 补充专业的中文文档型注释
- 解释重构的关键决策
- 说明性能与架构改进点
输出约束:
- 仅返回重构后的代码
- 保持代码原有风格
- 注释清晰专业
</system>

View File

@ -0,0 +1,45 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 专业领域科普作家
- 知识传播与教育专家
- 多媒体内容策划师
写作目标:
1. 开篇导读
- 话题背景介绍
- 阅读难度预期
2. 核心概念解析
- 专业术语通俗化
- 基础原理清晰化
- 生活案例类比
- 历史发展脉络
3. 深度知识传递
- 科学原理剖析
- 技术发展前沿
- 争议观点评述
- 实践应用场景
4. 互动与延展
- 趣味实验设计
- 思考问题引导
- 扩展阅读推荐
- 知识图谱构建
写作风格要求:
- 全程使用平实的中文
- 深入浅出、生动有趣
- 严谨专业、符合科学
- 分层递进、逻辑清晰
输出标准:
- 确保内容准确性
- 保持叙事连贯性
- 突出知识实用性
- 强调趣味性与启发性
质量控制:
- 引用权威来源
- 多角度交叉验证
输出约束:
- 避免过度技术化表达
- 规避未经验证的观点
- 考虑不同年龄层次需求
</system>

9
.dockerignore Executable file
View File

@ -0,0 +1,9 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
dist
test
.md
volumes
*.tar

71
.gitignore vendored Executable file
View File

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

1
.npmrc Executable file
View File

@ -0,0 +1 @@
node-linker=hoisted

102
Dockerfile Executable file
View File

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

14
apps/server/.env.example Executable file
View File

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

40
apps/server/.eslintrc.js Executable file
View File

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

4
apps/server/.prettierrc Executable file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

73
apps/server/README.md Executable file
View File

@ -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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).

41
apps/server/entrypoint.sh Executable file
View File

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

8
apps/server/nest-cli.json Executable file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

113
apps/server/package.json Executable file
View File

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

52
apps/server/src/app.module.ts Executable file
View File

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

View File

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

View File

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

View File

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

View File

@ -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: '注销成功' };
}
}

9
apps/server/src/auth/config.ts Executable file
View File

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

View File

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

31
apps/server/src/auth/types.ts Executable file
View File

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

193
apps/server/src/auth/utils.ts Executable file
View File

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

3
apps/server/src/env.ts Executable file
View File

@ -0,0 +1,3 @@
export const env: { JWT_SECRET: string } = {
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA='
}

View File

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

34
apps/server/src/main.ts Executable file
View File

@ -0,0 +1,34 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TrpcRouter } from './trpc/trpc.router';
import { WebSocketService } from './socket/websocket.service';
/**
* NestJS
* NestJS CORS WebSocket TRPC
*/
async function bootstrap() {
// 创建 NestJS 应用实例,使用 AppModule 作为根模块
const app = await NestFactory.create(AppModule);
// 启用 CORS 并允许所有来源
app.enableCors({ //允许跨域
origin: '*',
});
// 从 NestJS 应用实例中获取 WebSocketService 实例
const wsService = app.get(WebSocketService);
// 初始化 WebSocket 服务,传入 HTTP 服务器实例
await wsService.initialize(app.getHttpServer());
// 从 NestJS 应用实例中获取 TrpcRouter 实例
const trpc = app.get(TrpcRouter);
// 应用 TRPC 中间件到 NestJS 应用实例
trpc.applyMiddleware(app);
// 获取环境变量中的服务器端口,如果未设置则使用默认端口 3000
const port = process.env.SERVER_PORT || 3000;
// 启动服务器监听指定端口
await app.listen(port);
}
// 调用引导函数启动应用程序
bootstrap();

View File

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

View File

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

View File

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

View File

@ -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 ,cursortakewhereorderByselect等字段
* @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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 - IDID的输入参数
* @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');
}
}

View File

@ -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列表
* :
* - 使findManyID列表查询相关部门的员工信息
* - 使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);
}

View File

@ -0,0 +1,41 @@
import { Controller, Get, Query, Param } from '@nestjs/common';
@Controller('goods')
export class GoodsController {
constructor() {
console.log('goods controller')
}
// 示例1基本查询参数
@Get('hello')
getHello(@Query('name') name?: string) {
return {
message: 'Hello World!',
name: name || 'Guest'
};
}
// 示例2路径参数
@Get('detail/:id')
getDetail(@Param('id') id: string) {
return {
id: id,
detail: `Detail for product ${id}`
};
}
// 示例3多个查询参数
@Get('search')
searchProducts(
@Query('keyword') keyword: string,
@Query('page') page: number = 1,
@Query('limit') limit: number = 10
) {
return {
keyword,
page,
limit,
results: []
};
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GoodsService } from './goods.service';
import { GoodsController } from './goods.controller';
@Module({
providers: [GoodsService],
controllers: [GoodsController]
})
export class GoodsModule {}

View File

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class GoodsService {}

View File

@ -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';
// /message/find-last-one?staff-id=1
@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,
};
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 [];
}
}

View File

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

View File

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

View File

@ -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 IDID和对象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 };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
apps/server/src/queue/types.ts Executable file
View File

@ -0,0 +1,3 @@
export enum QueueJobType {
FILE_PROCESS = 'file_process',
}

View File

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

View File

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

View File

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

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