12300826
This commit is contained in:
parent
3f306f07a1
commit
c8dfb4db25
|
@ -0,0 +1,45 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 高级软件开发工程师
|
||||
- 代码文档化与知识传播专家
|
||||
|
||||
注释目标:
|
||||
1. 顶部注释
|
||||
- 模块/文件整体功能描述
|
||||
- 版本历史
|
||||
- 使用场景
|
||||
|
||||
2. 类注释
|
||||
- 类的职责和设计意图
|
||||
- 核心功能概述
|
||||
- 设计模式解析
|
||||
- 使用示例
|
||||
|
||||
3. 方法/函数注释
|
||||
- 功能详细描述
|
||||
- 输入参数解析
|
||||
- 返回值说明
|
||||
- 异常处理机制
|
||||
- 算法复杂度
|
||||
- 时间/空间性能分析
|
||||
|
||||
4. 代码块注释
|
||||
- 逐行解释代码意图
|
||||
- 关键语句原理阐述
|
||||
- 高级语言特性解读
|
||||
- 潜在的设计考量
|
||||
|
||||
注释风格要求:
|
||||
- 全程使用中文
|
||||
- 专业、清晰、通俗易懂
|
||||
- 面向初学者的知识传递
|
||||
- 保持技术严谨性
|
||||
|
||||
输出约束:
|
||||
- 仅返回添加注释后的代码
|
||||
- 注释与代码完美融合
|
||||
- 保持原代码结构不变
|
||||
</system>
|
|
@ -0,0 +1,45 @@
|
|||
<system>
|
||||
角色定位:
|
||||
- 身份: 高级错误处理与诊断工程师
|
||||
- 专业能力: 深入系统异常分析与解决
|
||||
- 分析维度: 错误类型、根因追踪、修复策略
|
||||
|
||||
错误处理分析要求:
|
||||
1. 错误详细诊断
|
||||
- 精确定位错误来源
|
||||
- 追踪完整错误调用链
|
||||
- 分析潜在影响范围
|
||||
|
||||
2. 错误分类与解析
|
||||
- 错误类型精确分类
|
||||
- 技术根因深度剖析
|
||||
- 系统架构潜在风险评估
|
||||
|
||||
3. 修复方案设计
|
||||
- 提供多层次修复建议
|
||||
- 评估每种方案的优缺点
|
||||
- 给出最优实施路径
|
||||
|
||||
4. 预防性建议
|
||||
- 提出系统防御性编程策略
|
||||
- 设计错误拦截与处理机制
|
||||
- 推荐代码健壮性改进方案
|
||||
|
||||
输出规范:
|
||||
- 错误报告格式化文档
|
||||
- 中英文专业技术术语精准使用
|
||||
- 层次清晰、逻辑严密
|
||||
- 技术性、建设性并重
|
||||
|
||||
报告要素:
|
||||
1. 错误摘要
|
||||
2. 详细诊断报告
|
||||
3. 根因分析
|
||||
4. 修复方案
|
||||
5. 预防建议
|
||||
|
||||
禁止:
|
||||
- 避免泛泛而谈
|
||||
- 不提供无依据的猜测
|
||||
- 严格遵循技术分析逻辑
|
||||
</system>
|
|
@ -0,0 +1,30 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 身份: 高级软件开发工程师
|
||||
- 专业能力: 深入代码架构分析
|
||||
- 分析维度: 技术、设计、性能、最佳实践
|
||||
|
||||
分析要求:
|
||||
1. 代码逐行详细注释
|
||||
2. 注释必须包含:
|
||||
- 代码意图解析
|
||||
- 技术原理阐述
|
||||
- 数据结构解读
|
||||
- 算法复杂度分析
|
||||
- 可能的优化建议
|
||||
|
||||
输出规范:
|
||||
- 全中文专业技术文档注释
|
||||
- 注释风格: 标准文档型
|
||||
- 保留原代码结构
|
||||
- 注释与代码同步展示
|
||||
- 技术性、专业性并重
|
||||
|
||||
禁止:
|
||||
- 不返回无关说明
|
||||
- 不进行无意义的介绍
|
||||
- strictly遵循技术分析本身
|
||||
</system>
|
|
@ -0,0 +1,39 @@
|
|||
角色定位:
|
||||
- 身份: 资深前端架构师
|
||||
- 专业能力: React组件设计与重构
|
||||
- 分析维度: 组件性能、可维护性、代码规范
|
||||
|
||||
重构分析要求:
|
||||
1. 组件代码全面评估
|
||||
2. 重构目标:
|
||||
- 提升组件渲染性能
|
||||
- 优化代码结构
|
||||
- 增强组件复用性
|
||||
- 遵循React最佳实践
|
||||
|
||||
重构评估维度:
|
||||
- 状态管理是否合理
|
||||
- 渲染性能分析
|
||||
- Hook使用规范
|
||||
- 组件拆分颗粒度
|
||||
- 依赖管理
|
||||
- 类型安全
|
||||
|
||||
重构输出要求:
|
||||
1. 详细重构方案
|
||||
2. 每个重构点需包含:
|
||||
- 当前问题描述
|
||||
- 重构建议
|
||||
- 重构后代码示例
|
||||
- 性能/架构提升说明
|
||||
|
||||
重构原则:
|
||||
- 保持原有业务逻辑不变
|
||||
- 代码简洁、可读性强
|
||||
- 遵循函数式编程思想
|
||||
- 类型安全优先
|
||||
|
||||
禁止:
|
||||
- 过度工程化
|
||||
- 不切实际的重构
|
||||
- 损害可读性的过度抽象
|
|
@ -0,0 +1,52 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
角色定位:
|
||||
- 高级软件架构师
|
||||
- 代码质量与性能改进专家
|
||||
|
||||
重构核心目标:
|
||||
1. 代码质量提升
|
||||
- 消除代码坏味道
|
||||
- 提高可读性
|
||||
- 增强可维护性
|
||||
- 优化代码结构
|
||||
|
||||
2. 架构设计优化
|
||||
- 应用合适的设计模式
|
||||
- 提升代码解耦程度
|
||||
- 增强系统扩展性
|
||||
- 改进模块间交互
|
||||
|
||||
3. 性能与资源优化
|
||||
- 算法复杂度改进
|
||||
- 内存使用效率
|
||||
- 计算资源利用率
|
||||
- 减少不必要的计算开销
|
||||
|
||||
4. 健壮性增强
|
||||
- 完善异常处理机制
|
||||
- 增加错误边界保护
|
||||
- 提高代码容错能力
|
||||
- 规范化错误处理流程
|
||||
|
||||
重构原则:
|
||||
- 保持原始功能不变
|
||||
- 遵循SOLID设计原则
|
||||
- 代码简洁性
|
||||
- 高内聚低耦合
|
||||
- 尽量使用语言特性
|
||||
- 避免过度设计
|
||||
|
||||
注释与文档要求:
|
||||
- 保留原有有效注释
|
||||
- 补充专业的中文文档型注释
|
||||
- 解释重构的关键决策
|
||||
- 说明性能与架构改进点
|
||||
|
||||
输出约束:
|
||||
- 仅返回重构后的代码
|
||||
- 保持代码原有风格
|
||||
- 注释清晰专业
|
||||
</system>
|
|
@ -26,38 +26,47 @@
|
|||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.1",
|
||||
"@nestjs/platform-socket.io": "^10.3.10",
|
||||
"@nestjs/schedule": "^4.1.0",
|
||||
"@nestjs/websockets": "^10.3.10",
|
||||
"@nicestack/common": "workspace:^",
|
||||
"@nicestack/common": "workspace:*",
|
||||
"@trpc/server": "11.0.0-rc.456",
|
||||
"axios": "^1.7.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"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",
|
||||
"ioredis": "^5.4.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"lib0": "^0.2.97",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"minio": "^8.0.1",
|
||||
"mitt": "^3.0.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"superjson-cjs": "^2.2.3",
|
||||
"tus-js-client": "^4.1.0"
|
||||
"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/bcrypt": "^5.0.2",
|
||||
"@types/exceljs": "^1.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@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",
|
||||
|
@ -71,7 +80,7 @@
|
|||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
@ -1,24 +1,50 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrpcModule } from './trpc/trpc.module';
|
||||
import { RedisService } from './redis/redis.service';
|
||||
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { SocketGateway } from './socket/socket.gateway';
|
||||
import { QueueModule } from './queue/queue.module';
|
||||
import { TransformModule } from './transform/transform.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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 { MinioModule } from './minio/minio.module';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot(), JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET
|
||||
}), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule, TasksModule, MinioModule],
|
||||
providers: [RedisService, SocketGateway, ConfigService],
|
||||
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
|
||||
],
|
||||
providers: [{
|
||||
provide: APP_FILTER,
|
||||
useClass: ExceptionsFilter,
|
||||
}],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
|
|
@ -1,38 +1,43 @@
|
|||
import { Controller, Post, Body, UseGuards, Get, Req } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthSchema, JwtPayload } from '@nicestack/common';
|
||||
import { z } from '@nicestack/common';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { UserProfileService } from './utils';
|
||||
import { z } from 'zod';
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get("user-profile")
|
||||
async getUserProfile(@Req() request: Request) {
|
||||
const user: JwtPayload = (request as any).user
|
||||
return this.authService.getUserProfile(user)
|
||||
}
|
||||
@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);
|
||||
}
|
||||
@UseGuards(AuthGuard) // Protecting the refreshToken endpoint with AuthGuard
|
||||
@Post('refresh-token')
|
||||
async refreshToken(@Body() body: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
return this.authService.refreshToken(body);
|
||||
}
|
||||
@UseGuards(AuthGuard) // Protecting the logout endpoint with 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);
|
||||
}
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { env } from '@server/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { StaffService } from '@server/models/staff/staff.service';
|
||||
import { RoleMapService } from '@server/rbac/rolemap.service';
|
||||
import { StaffModule } from '@server/models/staff/staff.module';
|
||||
import { AuthRouter } from './auth.router';
|
||||
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({
|
||||
providers: [AuthService, StaffService, RoleMapService, DepartmentService],
|
||||
imports: [StaffModule, RoleMapModule],
|
||||
providers: [AuthService, AuthRouter, TrpcService, DepartmentService, SessionService],
|
||||
exports: [AuthRouter, AuthService],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule { }
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { AuthSchema, ObjectModelMethodSchema } from '@nicestack/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
signUp: this.trpc.procedure
|
||||
.input(AuthSchema.signUpRequest)
|
||||
.mutation(async ({ input }) => {
|
||||
const result = await this.authService.signUp(input);
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -2,170 +2,187 @@ import {
|
|||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { StaffService } from '../models/staff/staff.service';
|
||||
import {
|
||||
db,
|
||||
AuthSchema,
|
||||
JwtPayload,
|
||||
} from '@nicestack/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthSchema, db, z } from '@nicestack/common';
|
||||
import { StaffService } from '@server/models/staff/staff.service';
|
||||
import { JwtPayload } from '@nicestack/common';
|
||||
import { RoleMapService } from '@server/rbac/rolemap.service';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import { UserProfileService } from './utils';
|
||||
import { SessionInfo, SessionService } from './session.service';
|
||||
import { tokenConfig } from './config';
|
||||
import { z } from 'zod';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private logger = new Logger(AuthService.name)
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly roleMapService: RoleMapService
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionService: SessionService,
|
||||
) { }
|
||||
|
||||
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
|
||||
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;
|
||||
// Find the staff by either username or phoneNumber
|
||||
const staff = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ phoneNumber }
|
||||
]
|
||||
}
|
||||
|
||||
let staff = await db.staff.findFirst({
|
||||
where: { OR: [{ username }, { phoneNumber }], deletedAt: null },
|
||||
});
|
||||
|
||||
if (!staff) {
|
||||
throw new UnauthorizedException('Invalid username/phone number or password');
|
||||
if (!staff && phoneNumber) {
|
||||
staff = await this.signUp({
|
||||
showname: '新用户',
|
||||
username: phoneNumber,
|
||||
phoneNumber,
|
||||
password: phoneNumber,
|
||||
});
|
||||
} else if (!staff) {
|
||||
throw new UnauthorizedException('帐号不存在');
|
||||
}
|
||||
|
||||
const isPasswordMatch = await bcrypt.compare(password, staff.password);
|
||||
if (!staff.enabled) {
|
||||
throw new UnauthorizedException('帐号已禁用');
|
||||
}
|
||||
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password);
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('Invalid username/phone number or password');
|
||||
throw new UnauthorizedException('帐号或密码错误');
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: staff.id, username: staff.username };
|
||||
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' });
|
||||
const refreshToken = await this.generateRefreshToken(staff.id);
|
||||
try {
|
||||
const payload = { sub: staff.id, username: staff.username };
|
||||
const { accessToken, refreshToken } = await this.generateTokens(payload);
|
||||
|
||||
// Calculate expiration dates
|
||||
const accessTokenExpiresAt = new Date();
|
||||
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
|
||||
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 refreshTokenExpiresAt = new Date();
|
||||
refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7);
|
||||
|
||||
// Store the refresh token in the database
|
||||
await db.refreshToken.create({
|
||||
data: {
|
||||
staffId: staff.id,
|
||||
token: refreshToken,
|
||||
const existingUser = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { officerId }, { phoneNumber }],
|
||||
deletedAt: null
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
refresh_token: refreshToken,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('帐号或证件号已存在');
|
||||
}
|
||||
|
||||
async generateRefreshToken(userId: string): Promise<string> {
|
||||
const payload = { sub: userId };
|
||||
return this.jwtService.signAsync(payload, { expiresIn: '7d' }); // Set an appropriate expiration
|
||||
return this.staffService.create({
|
||||
data: {
|
||||
...data,
|
||||
domainId: data.deptId,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
const { refreshToken } = data;
|
||||
const { refreshToken, sessionId } = data;
|
||||
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify(refreshToken);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
} catch {
|
||||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } });
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
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 } });
|
||||
const user = await db.staff.findUnique({ where: { id: payload.sub, deletedAt: null } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const newAccessToken = await this.jwtService.signAsync(
|
||||
{ sub: user.id, username: user.username },
|
||||
{ expiresIn: '1h' },
|
||||
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,
|
||||
);
|
||||
|
||||
// Calculate new expiration date
|
||||
const accessTokenExpiresAt = new Date();
|
||||
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 1);
|
||||
|
||||
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub));
|
||||
return {
|
||||
access_token: newAccessToken,
|
||||
access_token_expires_at: accessTokenExpiresAt,
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: updatedSession.access_token_expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
|
||||
const { username, password, phoneNumber } = data;
|
||||
|
||||
const existingUserByUsername = await db.staff.findUnique({ where: { username } });
|
||||
if (existingUserByUsername) {
|
||||
throw new BadRequestException('Username is already taken');
|
||||
}
|
||||
if (phoneNumber) {
|
||||
const existingUserByPhoneNumber = await db.staff.findUnique({ where: { phoneNumber } });
|
||||
if (existingUserByPhoneNumber) {
|
||||
throw new BadRequestException('Phone number is already taken');
|
||||
}
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const staff = await this.staffService.create({
|
||||
username,
|
||||
phoneNumber,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return staff;
|
||||
}
|
||||
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
|
||||
const { refreshToken } = data;
|
||||
await db.refreshToken.deleteMany({ where: { token: refreshToken } });
|
||||
return { message: 'Logout successful' };
|
||||
}
|
||||
|
||||
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
|
||||
const { oldPassword, newPassword, username } = data;
|
||||
const user = await db.staff.findUnique({ where: { username } });
|
||||
const { newPassword, phoneNumber, username } = data;
|
||||
const user = await db.staff.findFirst({
|
||||
where: { OR: [{ username }, { phoneNumber }], deletedAt: null },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
const isPasswordMatch = await bcrypt.compare(oldPassword, user.password);
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('Old password is incorrect');
|
||||
}
|
||||
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
|
||||
await this.staffService.update({ id: user.id, password: hashedNewPassword });
|
||||
|
||||
return { message: 'Password successfully changed' };
|
||||
}
|
||||
async getUserProfile(data: JwtPayload) {
|
||||
const { sub } = data
|
||||
const staff = await db.staff.findUnique({
|
||||
where: { id: sub }, include: {
|
||||
department: true,
|
||||
domain: true
|
||||
await this.staffService.update({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
password: newPassword,
|
||||
}
|
||||
})
|
||||
const staffPerms = await this.roleMapService.getPermsForObject({
|
||||
domainId: staff.domainId,
|
||||
staffId: staff.id,
|
||||
deptId: staff.deptId,
|
||||
});
|
||||
return { ...staff, permissions: staffPerms }
|
||||
|
||||
return { message: '密码已修改' };
|
||||
}
|
||||
}
|
||||
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
|
||||
const { refreshToken, sessionId } = data;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken);
|
||||
await Promise.all([
|
||||
this.sessionService.deleteSession(payload.sub, sessionId),
|
||||
redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)),
|
||||
]);
|
||||
} catch {
|
||||
throw new UnauthorizedException('无效的会话');
|
||||
}
|
||||
|
||||
return { message: '注销成功' };
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export const tokenConfig = {
|
||||
accessToken: {
|
||||
expirationMs: 7 * 24 * 3600000, // 7 days
|
||||
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
},
|
||||
refreshToken: {
|
||||
expirationMs: 30 * 24 * 3600000, // 30 days
|
||||
},
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
// session.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface SessionInfo {
|
||||
session_id: string;
|
||||
access_token: string;
|
||||
access_token_expires_at: number;
|
||||
refresh_token: string;
|
||||
refresh_token_expires_at: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private getSessionKey(userId: string, sessionId: string): string {
|
||||
return `session-${userId}-${sessionId}`;
|
||||
}
|
||||
async createSession(
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expirationConfig: {
|
||||
accessTokenExpirationMs: number;
|
||||
refreshTokenExpirationMs: number;
|
||||
sessionTTL: number;
|
||||
},
|
||||
): Promise<SessionInfo> {
|
||||
const sessionInfo: SessionInfo = {
|
||||
session_id: uuidv4(),
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs,
|
||||
refresh_token: refreshToken,
|
||||
refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs,
|
||||
};
|
||||
|
||||
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
async getSession(userId: string, sessionId: string): Promise<SessionInfo | null> {
|
||||
const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
|
||||
return sessionData ? JSON.parse(sessionData) : null;
|
||||
}
|
||||
|
||||
async saveSession(
|
||||
userId: string,
|
||||
sessionInfo: SessionInfo,
|
||||
ttl: number,
|
||||
): Promise<void> {
|
||||
await redis.setex(
|
||||
this.getSessionKey(userId, sessionInfo.session_id),
|
||||
ttl,
|
||||
JSON.stringify(sessionInfo),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSession(userId: string, sessionId: string): Promise<void> {
|
||||
await redis.del(this.getSessionKey(userId, sessionId));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export interface TokenConfig {
|
||||
accessToken: {
|
||||
expirationMs: number;
|
||||
expirationTTL: number;
|
||||
};
|
||||
refreshToken: {
|
||||
expirationMs: number;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
JwtPayload,
|
||||
RolePerms,
|
||||
ObjectType,
|
||||
} from '@nicestack/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';
|
||||
|
||||
interface ProfileResult {
|
||||
staff: UserProfile | undefined;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TokenVerifyResult {
|
||||
id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
export const env: { JWT_SECRET: string, APP_URL: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA=',
|
||||
APP_URL: process.env.APP_URL || 'http://localhost:5173'
|
||||
export const env: { JWT_SECRET: string } = {
|
||||
JWT_SECRET: process.env.JWT_SECRET || '/yT9MnLm/r6NY7ee2Fby6ihCHZl+nFx4OQFKupivrhA='
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { InitService } from './init.service';
|
||||
import { AuthModule } from '@server/auth/auth.module';
|
||||
import { MinioModule } from '@server/minio/minio.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, MinioModule],
|
||||
providers: [InitService],
|
||||
exports: [InitService]
|
||||
})
|
||||
export class InitModule { }
|
|
@ -1,16 +1,23 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { TrpcRouter } from './trpc/trpc.router';
|
||||
import { env } from './env';
|
||||
import { WebSocketService } from './socket/websocket.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 启用 CORS 并允许所有来源
|
||||
app.enableCors({
|
||||
origin: [env.APP_URL],
|
||||
credentials: true
|
||||
origin: "*",
|
||||
});
|
||||
const wsService = app.get(WebSocketService);
|
||||
await wsService.initialize(app.getHttpServer());
|
||||
const trpc = app.get(TrpcRouter);
|
||||
trpc.applyMiddleware(app);
|
||||
await app.listen(3000);
|
||||
|
||||
const port = process.env.SERVER_PORT || 3000;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { AppConfigRouter } from './app-config.router';
|
||||
import { RealTimeModule } from '@server/socket/realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [RealTimeModule],
|
||||
providers: [AppConfigService, AppConfigRouter, TrpcService],
|
||||
exports: [AppConfigService, AppConfigRouter]
|
||||
})
|
||||
export class AppConfigModule { }
|
|
@ -0,0 +1,47 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
import { Prisma } from '@nicestack/common';
|
||||
import { RealtimeServer } from '@server/socket/realtime/realtime.server';
|
||||
const AppConfigUncheckedCreateInputSchema: ZodType<Prisma.AppConfigUncheckedCreateInput> = z.any()
|
||||
const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any()
|
||||
const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> = z.any()
|
||||
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> = z.any()
|
||||
@Injectable()
|
||||
export class AppConfigRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly realtimeServer: RealtimeServer
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(AppConfigUncheckedCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.appConfigService.create({ data: input });
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(AppConfigUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
const { staff } = ctx;
|
||||
return await this.appConfigService.update(input);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure.input(AppConfigDeleteManyArgsSchema).mutation(async ({ input }) => {
|
||||
return await this.appConfigService.deleteMany(input)
|
||||
}),
|
||||
findFirst: this.trpc.protectProcedure.input(AppConfigFindFirstArgsSchema).
|
||||
query(async ({ input }) => {
|
||||
|
||||
return await this.appConfigService.findFirst(input)
|
||||
}),
|
||||
clearRowCache: this.trpc.protectProcedure.mutation(async () => {
|
||||
return await this.appConfigService.clearRowCache()
|
||||
}),
|
||||
getClientCount: this.trpc.protectProcedure.query(() => {
|
||||
return this.realtimeServer.getClientCount()
|
||||
})
|
||||
});
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
} from '@nicestack/common';
|
||||
|
||||
|
||||
import { BaseService } from '../base/base.service';
|
||||
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||
|
||||
@Injectable()
|
||||
export class AppConfigService extends BaseService<Prisma.AppConfigDelegate> {
|
||||
constructor() {
|
||||
super(db, "appConfig");
|
||||
}
|
||||
async clearRowCache() {
|
||||
await deleteByPattern("row-*")
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,562 @@
|
|||
import { db, Prisma, PrismaClient } from '@nicestack/common';
|
||||
import {
|
||||
Operations,
|
||||
DelegateArgs,
|
||||
DelegateReturnTypes,
|
||||
DataArgs,
|
||||
WhereArgs,
|
||||
DelegateFuncs,
|
||||
UpdateOrderArgs,
|
||||
TransactionType,
|
||||
} 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');
|
||||
throw error; // Re-throw the error to be handled higher up
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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['findUnique']>;
|
||||
}): Promise<R['findMany']> {
|
||||
const { page, pageSize, where } = args;
|
||||
try {
|
||||
return this.getModel().findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
} as any) as Promise<R['findMany']>;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 基于游标的分页查询方法
|
||||
* @description 该方法实现了基于游标的分页查询,相比传统的offset/limit分页有更好的性能
|
||||
* @param args 查询参数对象,包含cursor、take、where、orderBy、select等字段
|
||||
* @returns 返回查询结果对象,包含items数组、总数和下一页游标
|
||||
*/
|
||||
async findManyWithCursor(
|
||||
args: A['findMany'],
|
||||
): Promise<{ items: R['findMany']; nextCursor: string }> {
|
||||
// 解构查询参数,设置默认每页取10条记录
|
||||
const { cursor, take = 6, where, orderBy, select } = args as any;
|
||||
|
||||
try {
|
||||
|
||||
/**
|
||||
* 执行查询
|
||||
* @description 查询条件包含:
|
||||
* 1. where - 过滤条件
|
||||
* 2. orderBy - 排序规则,除了传入的排序外,还加入updatedAt和id的降序作为稳定排序
|
||||
* 3. select - 选择返回的字段
|
||||
* 4. take - 实际取n+1条记录,用于判断是否还有下一页
|
||||
* 5. cursor - 游标定位,基于updatedAt和id的组合
|
||||
*/
|
||||
const items = (await this.getModel().findMany({
|
||||
where: where,
|
||||
orderBy: [{ ...orderBy }, { updatedAt: 'desc' }, { id: 'desc' }],
|
||||
select,
|
||||
take: take + 1,
|
||||
cursor: cursor
|
||||
? { updatedAt: cursor.split('_')[0], id: cursor.split('_')[1] }
|
||||
: undefined,
|
||||
} as any)) as any[];
|
||||
|
||||
|
||||
/**
|
||||
* 处理下一页游标
|
||||
* @description
|
||||
* 1. 如果查到的记录数超过take,说明还有下一页
|
||||
* 2. 将最后一条记录弹出,用其updatedAt和id构造下一页游标
|
||||
* 3. 游标格式为: updatedAt_id
|
||||
*/
|
||||
let nextCursor: string | null = '';
|
||||
if (items.length > take) {
|
||||
const nextItem = items.pop();
|
||||
nextCursor = `${nextItem!.updatedAt?.toISOString()}_${nextItem!.id}`;
|
||||
}
|
||||
if (nextCursor === '') {
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回查询结果
|
||||
* @returns {Object}
|
||||
* - items: 当前页记录
|
||||
* - totalCount: 总记录数
|
||||
* - nextCursor: 下一页游标
|
||||
*/
|
||||
return {
|
||||
items: items as R['findMany'],
|
||||
nextCursor: nextCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleError(error, 'read');
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrder(args: UpdateOrderArgs) {
|
||||
const { id, overId } = args;
|
||||
const [currentObject, targetObject] = (await Promise.all([
|
||||
this.findFirst({ where: { id } } as any),
|
||||
this.findFirst({ where: { id: overId } } as any),
|
||||
])) as any;
|
||||
if (!currentObject || !targetObject) {
|
||||
throw new Error('Invalid object or target object');
|
||||
}
|
||||
const nextObject = (await this.findFirst({
|
||||
where: {
|
||||
order: { gt: targetObject.order },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { order: 'asc' }
|
||||
} as any)) as any;
|
||||
|
||||
const newOrder = nextObject
|
||||
? (targetObject.order + nextObject.order) / 2
|
||||
: targetObject.order + this.ORDER_INTERVAL;
|
||||
return this.update({ where: { id }, data: { order: newOrder } } as any);
|
||||
}
|
||||
/**
|
||||
* Wraps the result of a database operation with a transformation function.
|
||||
* @template T - The type of the result to be transformed.
|
||||
* @param operationPromise - The promise representing the database operation.
|
||||
* @param transformFn - A function that transforms the result.
|
||||
* @returns {Promise<T>} - A promise resolving to the transformed result.
|
||||
* @example
|
||||
* const user = await service.wrapResult(
|
||||
* service.findUnique({ where: { id: 'user_id' } }),
|
||||
* (result) => ({ ...result, fullName: `${result.firstName} ${result.lastName}` })
|
||||
* );
|
||||
*/
|
||||
async wrapResult<T>(
|
||||
operationPromise: Promise<T>,
|
||||
transformFn: (result: T) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await operationPromise;
|
||||
return await transformFn(result);
|
||||
} catch (error) {
|
||||
throw error; // Re-throw the error to maintain existing error handling
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,389 @@
|
|||
import { Prisma, PrismaClient } from '@nicestack/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']) {
|
||||
const anyArgs = args as any
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
if (this.enableOrder) {
|
||||
// 获取新节点的 order
|
||||
anyArgs.data.order = await this.getNextOrder(
|
||||
transaction,
|
||||
anyArgs?.data.parentId ?? null
|
||||
);
|
||||
}
|
||||
// 创建节点
|
||||
const result: any = await super.create(anyArgs, { tx: transaction });
|
||||
|
||||
// 更新父节点的 hasChildren 状态
|
||||
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;
|
||||
}) as Promise<R['create']>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新现有单位,并在parentId更改时管理DeptAncestry关系。
|
||||
* @param data - 用于更新现有单位的数据。
|
||||
* @returns 更新后的单位对象。
|
||||
*/
|
||||
async update(args: A['update']) {
|
||||
const anyArgs = args as any
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const current = await transaction[this.objectType].findUnique({
|
||||
where: { id: anyArgs.where.id },
|
||||
});
|
||||
|
||||
if (!current) throw new Error('object not found');
|
||||
|
||||
const result: any = await super.update(anyArgs, { tx: transaction });
|
||||
|
||||
if (anyArgs.data.parentId !== current.parentId) {
|
||||
await transaction[this.ancestryType].deleteMany({
|
||||
where: { descendantId: result.id },
|
||||
});
|
||||
// 更新原父级的 hasChildren 状态
|
||||
if (current.parentId) {
|
||||
const childrenCount = await transaction[this.objectType].count({
|
||||
where: { parentId: current.parentId, deletedAt: null }
|
||||
});
|
||||
|
||||
if (childrenCount === 0) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: current.parentId },
|
||||
data: { hasChildren: false }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (anyArgs.data.parentId) {
|
||||
await transaction[this.objectType].update({
|
||||
where: { id: anyArgs.data.parentId },
|
||||
data: { hasChildren: true }
|
||||
});
|
||||
const parentAncestries = await transaction[this.ancestryType].findMany({
|
||||
where: { descendantId: anyArgs.data.parentId },
|
||||
});
|
||||
|
||||
const newAncestries = parentAncestries.map(
|
||||
({ ancestorId, relDepth }) => ({
|
||||
ancestorId,
|
||||
descendantId: result.id,
|
||||
relDepth: relDepth + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
newAncestries.push({
|
||||
ancestorId: anyArgs.data.parentId,
|
||||
descendantId: result.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await transaction[this.ancestryType].createMany({ data: newAncestries });
|
||||
} else {
|
||||
await transaction[this.ancestryType].create({
|
||||
data: { ancestorId: null, descendantId: result.id, relDepth: 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as Promise<R['update']>;
|
||||
}
|
||||
/**
|
||||
* Soft deletes records by setting `isDeleted` to true for the given IDs.
|
||||
* @param ids - An array of IDs of the records to soft delete.
|
||||
* @param data - Additional data to update on soft delete. (Optional)
|
||||
* @returns {Promise<R['update'][]>} - A promise resolving to an array of updated records.
|
||||
* @example
|
||||
* const softDeletedUsers = await service.softDeleteByIds(['user_id1', 'user_id2'], { reason: 'Bulk deletion' });
|
||||
*/
|
||||
async softDeleteByIds(
|
||||
ids: string[],
|
||||
data: Partial<DataArgs<A['update']>> = {}, // Default to empty object
|
||||
): Promise<R['update'][]> {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
// 首先找出所有需要软删除的记录的父级ID
|
||||
const parentIds = await tx[this.objectType].findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
parentId: { not: null }
|
||||
},
|
||||
select: { parentId: true }
|
||||
});
|
||||
|
||||
const uniqueParentIds = [...new Set(parentIds.map(p => p.parentId))];
|
||||
|
||||
// 执行软删除
|
||||
const result = await super.softDeleteByIds(ids, data);
|
||||
|
||||
// 删除相关的祖先关系
|
||||
await tx[this.ancestryType].deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: ids } },
|
||||
{ descendantId: { in: ids } },
|
||||
],
|
||||
}
|
||||
});
|
||||
// 更新父级的 hasChildren 状态
|
||||
if (uniqueParentIds.length > 0) {
|
||||
for (const parentId of uniqueParentIds) {
|
||||
const remainingChildrenCount = await tx[this.objectType].count({
|
||||
where: {
|
||||
parentId: parentId,
|
||||
deletedAt: null
|
||||
}
|
||||
});
|
||||
if (remainingChildrenCount === 0) {
|
||||
await tx[this.objectType].update({
|
||||
where: { id: parentId },
|
||||
data: { hasChildren: false }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}) as Promise<R['update'][]>;
|
||||
}
|
||||
|
||||
|
||||
getAncestors(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter(id => id != null);
|
||||
const hasNull = ids.includes(null)
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
]
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDescendants(ids: string[]) {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const validIds = ids.filter(id => id != null);
|
||||
const hasNull = ids.includes(null)
|
||||
return this.prisma[this.ancestryType].findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ancestorId: { in: validIds } },
|
||||
{ ancestorId: hasNull ? null : undefined },
|
||||
]
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDescendantIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
const res = await this.getDescendants(idArray);
|
||||
const descendantSet = new Set(res?.map((item) => item.descendantId) || []);
|
||||
|
||||
if (includeOriginalIds) {
|
||||
idArray.forEach(id => descendantSet.add(id));
|
||||
}
|
||||
|
||||
return Array.from(descendantSet).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
async getAncestorIds(ids: string | string[], includeOriginalIds: boolean = false): Promise<string[]> {
|
||||
// 将单个 ID 转换为数组
|
||||
const idArray = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
const res = await this.getDescendants(idArray);
|
||||
const ancestorSet = new Set<string>();
|
||||
|
||||
// 按深度排序并添加祖先ID
|
||||
res
|
||||
?.sort((a, b) => b.relDepth - a.relDepth)
|
||||
?.forEach((item) => ancestorSet.add(item.ancestorId));
|
||||
|
||||
// 根据参数决定是否添加原始ID
|
||||
if (includeOriginalIds) {
|
||||
idArray.forEach((id) => ancestorSet.add(id));
|
||||
}
|
||||
|
||||
return Array.from(ancestorSet).filter(Boolean) as string[];
|
||||
}
|
||||
async updateOrder(args: UpdateOrderArgs) {
|
||||
const { id, overId } = args;
|
||||
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
// 查找当前节点和目标节点
|
||||
const currentObject = await transaction[this.objectType].findUnique({
|
||||
where: { id },
|
||||
select: { id: true, parentId: true, order: true }
|
||||
});
|
||||
|
||||
const targetObject = await transaction[this.objectType].findUnique({
|
||||
where: { id: overId },
|
||||
select: { id: true, parentId: true, order: true }
|
||||
});
|
||||
|
||||
// 验证节点
|
||||
if (!currentObject || !targetObject) {
|
||||
throw new Error('Invalid object or target object');
|
||||
}
|
||||
|
||||
// 查找父节点
|
||||
const parentObject = currentObject.parentId
|
||||
? await transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.parentId },
|
||||
select: { id: true, order: true }
|
||||
})
|
||||
: null;
|
||||
|
||||
// 确保在同一父节点下移动
|
||||
if (currentObject.parentId !== targetObject.parentId) {
|
||||
throw new Error('Cannot move between different parent nodes');
|
||||
}
|
||||
|
||||
// 查找同层级的所有节点,按 order 排序
|
||||
const siblingNodes = await transaction[this.objectType].findMany({
|
||||
where: {
|
||||
parentId: targetObject.parentId
|
||||
},
|
||||
select: { id: true, order: true },
|
||||
orderBy: { order: 'asc' }
|
||||
});
|
||||
|
||||
// 找到目标节点和当前节点在兄弟节点中的索引
|
||||
const targetIndex = siblingNodes.findIndex(node => node.id === targetObject.id);
|
||||
const currentIndex = siblingNodes.findIndex(node => node.id === currentObject.id);
|
||||
|
||||
// 移除当前节点
|
||||
siblingNodes.splice(currentIndex, 1);
|
||||
|
||||
// 在目标位置插入当前节点
|
||||
const insertIndex = currentIndex > targetIndex ? targetIndex + 1 : targetIndex;
|
||||
siblingNodes.splice(insertIndex, 0, currentObject);
|
||||
|
||||
|
||||
// 重新分配 order
|
||||
const newOrders = this.redistributeOrder(siblingNodes, parentObject?.order || 0);
|
||||
|
||||
// 批量更新节点的 order
|
||||
const updatePromises = newOrders.map((nodeOrder, index) =>
|
||||
transaction[this.objectType].update({
|
||||
where: { id: siblingNodes[index].id },
|
||||
data: { order: nodeOrder }
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
// 返回更新后的当前节点
|
||||
return transaction[this.objectType].findUnique({
|
||||
where: { id: currentObject.id }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 重新分配 order 的方法
|
||||
private redistributeOrder(nodes: Array<{ id: string, order: number }>, parentOrder: number): number[] {
|
||||
const MIN_CHILD_ORDER = parentOrder + this.ORDER_INTERVAL; // 子节点 order 必须大于父节点
|
||||
const newOrders: number[] = [];
|
||||
|
||||
nodes.forEach((_, index) => {
|
||||
// 使用等差数列分配 order,确保大于父节点
|
||||
const nodeOrder = MIN_CHILD_ORDER + (index + 1) * this.ORDER_INTERVAL;
|
||||
newOrders.push(nodeOrder);
|
||||
});
|
||||
|
||||
return newOrders;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { db, Prisma, PrismaClient } from "@nicestack/common";
|
||||
|
||||
export type Operations =
|
||||
| 'aggregate'
|
||||
| 'count'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'delete'
|
||||
| 'deleteMany'
|
||||
| 'findFirst'
|
||||
| 'findMany'
|
||||
| 'findUnique'
|
||||
| 'update'
|
||||
| 'updateMany'
|
||||
| 'upsert';
|
||||
export type DelegateFuncs = { [K in Operations]: (args: any) => Promise<unknown> }
|
||||
export type DelegateArgs<T> = {
|
||||
[K in keyof T]: T[K] extends (args: infer A) => Promise<any> ? A : never;
|
||||
};
|
||||
|
||||
export type DelegateReturnTypes<T> = {
|
||||
[K in keyof T]: T[K] extends (args: any) => Promise<infer R> ? R : never;
|
||||
};
|
||||
|
||||
export type WhereArgs<T> = T extends { where: infer W } ? W : never;
|
||||
export type SelectArgs<T> = T extends { select: infer S } ? S : never;
|
||||
export type DataArgs<T> = T extends { data: infer D } ? D : never;
|
||||
export type IncludeArgs<T> = T extends { include: infer I } ? I : never;
|
||||
export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never;
|
||||
export type UpdateOrderArgs = {
|
||||
id: string
|
||||
overId: string
|
||||
}
|
||||
export interface FindManyWithCursorType<T extends DelegateFuncs> {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
where?: WhereArgs<DelegateArgs<T>['findUnique']>;
|
||||
select?: SelectArgs<DelegateArgs<T>['findUnique']>;
|
||||
orderBy?: OrderByArgs<DelegateArgs<T>['findMany']>
|
||||
}
|
||||
export type TransactionType = Omit<
|
||||
PrismaClient,
|
||||
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
||||
>;
|
|
@ -0,0 +1,198 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
export const PrismaErrorCode = Object.freeze({
|
||||
P2000: 'P2000',
|
||||
P2001: 'P2001',
|
||||
P2002: 'P2002',
|
||||
P2003: 'P2003',
|
||||
P2006: 'P2006',
|
||||
P2007: 'P2007',
|
||||
P2008: 'P2008',
|
||||
P2009: 'P2009',
|
||||
P2010: 'P2010',
|
||||
P2011: 'P2011',
|
||||
P2012: 'P2012',
|
||||
P2014: 'P2014',
|
||||
P2015: 'P2015',
|
||||
P2016: 'P2016',
|
||||
P2017: 'P2017',
|
||||
P2018: 'P2018',
|
||||
P2019: 'P2019',
|
||||
P2021: 'P2021',
|
||||
P2023: 'P2023',
|
||||
P2025: 'P2025',
|
||||
P2031: 'P2031',
|
||||
P2033: 'P2033',
|
||||
P2034: 'P2034',
|
||||
P2037: 'P2037',
|
||||
P1000: 'P1000',
|
||||
P1001: 'P1001',
|
||||
P1002: 'P1002',
|
||||
P1015: 'P1015',
|
||||
P1017: 'P1017',
|
||||
});
|
||||
|
||||
export type PrismaErrorCode = keyof typeof PrismaErrorCode;
|
||||
|
||||
|
||||
interface PrismaErrorMeta {
|
||||
target?: string;
|
||||
model?: string;
|
||||
relationName?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export type operationT = 'create' | 'read' | 'update' | 'delete';
|
||||
|
||||
export type PrismaErrorHandler = (
|
||||
operation: operationT,
|
||||
meta?: PrismaErrorMeta,
|
||||
) => Error;
|
||||
|
||||
export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = {
|
||||
P2000: (_operation, meta) => new BadRequestException(
|
||||
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.`
|
||||
),
|
||||
|
||||
P2001: (operation, meta) => new NotFoundException(
|
||||
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`
|
||||
),
|
||||
|
||||
P2002: (operation, meta) => {
|
||||
const field = meta?.target || 'unique field';
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return new ConflictException(
|
||||
`A record with the same ${field} already exists. Please use a different value.`
|
||||
);
|
||||
case 'update':
|
||||
return new ConflictException(
|
||||
`The new value for ${field} conflicts with an existing record.`
|
||||
);
|
||||
default:
|
||||
return new ConflictException(
|
||||
`Unique constraint violation on ${field}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
P2003: (operation) => new BadRequestException(
|
||||
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`
|
||||
),
|
||||
|
||||
P2006: (_operation, meta) => new BadRequestException(
|
||||
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`
|
||||
),
|
||||
|
||||
P2007: (operation) => new InternalServerErrorException(
|
||||
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`
|
||||
),
|
||||
|
||||
P2008: (operation) => new InternalServerErrorException(
|
||||
`Failed to query the database during ${operation}. Please try again later.`
|
||||
),
|
||||
|
||||
P2009: (operation) => new InternalServerErrorException(
|
||||
`Invalid data fetched during ${operation}. Check query structure.`
|
||||
),
|
||||
|
||||
P2010: () => new InternalServerErrorException(
|
||||
`Invalid raw query. Ensure your query is correct and try again.`
|
||||
),
|
||||
|
||||
P2011: (_operation, meta) => new BadRequestException(
|
||||
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`
|
||||
),
|
||||
|
||||
P2012: (operation, meta) => new BadRequestException(
|
||||
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`
|
||||
),
|
||||
|
||||
P2014: (operation) => {
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return new BadRequestException(
|
||||
`Cannot create record because the referenced data does not exist. Ensure related data exists.`
|
||||
);
|
||||
case 'delete':
|
||||
return new BadRequestException(
|
||||
`Unable to delete record because it is linked to other data. Update or delete dependent records first.`
|
||||
);
|
||||
default:
|
||||
return new BadRequestException(`Foreign key constraint error.`);
|
||||
}
|
||||
},
|
||||
|
||||
P2015: () => new InternalServerErrorException(
|
||||
`A record with the required ID was expected but not found. Please retry.`
|
||||
),
|
||||
|
||||
P2016: (operation) => new InternalServerErrorException(
|
||||
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`
|
||||
),
|
||||
|
||||
P2017: (operation) => new InternalServerErrorException(
|
||||
`Connected records were not found for ${operation}. Check related data.`
|
||||
),
|
||||
|
||||
P2018: () => new InternalServerErrorException(
|
||||
`The required connection could not be established. Please check relationships.`
|
||||
),
|
||||
|
||||
P2019: (_operation, meta) => new InternalServerErrorException(
|
||||
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`
|
||||
),
|
||||
|
||||
P2021: (_operation, meta) => new InternalServerErrorException(
|
||||
`The ${meta?.model || 'model'} was not found in the database.`
|
||||
),
|
||||
|
||||
P2025: (operation, meta) => new NotFoundException(
|
||||
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`
|
||||
),
|
||||
|
||||
P2031: () => new InternalServerErrorException(
|
||||
`Invalid Prisma Client initialization error. Please check configuration.`
|
||||
),
|
||||
|
||||
P2033: (operation) => new InternalServerErrorException(
|
||||
`Insufficient database write permissions for ${operation}.`
|
||||
),
|
||||
|
||||
P2034: (operation) => new InternalServerErrorException(
|
||||
`Database read-only transaction failed during ${operation}.`
|
||||
),
|
||||
|
||||
P2037: (operation) => new InternalServerErrorException(
|
||||
`Unsupported combinations of input types for ${operation}. Please correct the query or input.`
|
||||
),
|
||||
|
||||
P1000: () => new InternalServerErrorException(
|
||||
`Database authentication failed. Verify your credentials and try again.`
|
||||
),
|
||||
|
||||
P1001: () => new InternalServerErrorException(
|
||||
`The database server could not be reached. Please check its availability.`
|
||||
),
|
||||
|
||||
P1002: () => new InternalServerErrorException(
|
||||
`Connection to the database timed out. Verify network connectivity and server availability.`
|
||||
),
|
||||
|
||||
P1015: (operation) => new InternalServerErrorException(
|
||||
`Migration failed. Unable to complete ${operation}. Check migration history or database state.`
|
||||
),
|
||||
|
||||
P1017: () => new InternalServerErrorException(
|
||||
`Database connection failed. Ensure the database is online and credentials are correct.`
|
||||
),
|
||||
P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { UserProfile, RowModelRequest, RowRequestSchema } from "@nicestack/common";
|
||||
import { RowModelService } from "./row-model.service";
|
||||
import { isFieldCondition, LogicalCondition, SQLBuilder } from "./sql-builder";
|
||||
import EventBus from "@server/utils/event-bus";
|
||||
import supejson from "superjson-cjs"
|
||||
import { deleteByPattern } from "@server/utils/redis/utils";
|
||||
import { redis } from "@server/utils/redis/redis.service";
|
||||
import { z } from "zod";
|
||||
export class RowCacheService extends RowModelService {
|
||||
constructor(tableName: string, private enableCache: boolean = true) {
|
||||
super(tableName)
|
||||
if (this.enableCache) {
|
||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
||||
if (type === tableName) {
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
for (const item of dataArray) {
|
||||
try {
|
||||
if (item.id) {
|
||||
this.invalidateRowCacheById(item.id)
|
||||
}
|
||||
if (item.parentId) {
|
||||
this.invalidateRowCacheById(item.parentId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error deleting cache for type ${tableName}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
protected getRowCacheKey(id: string) {
|
||||
return `row-data-${id}`;
|
||||
}
|
||||
private async invalidateRowCacheById(id: string) {
|
||||
if (!this.enableCache) return;
|
||||
const pattern = this.getRowCacheKey(id);
|
||||
await deleteByPattern(pattern);
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return []
|
||||
}
|
||||
protected async getRowRelation(args: { data: any, staff?: UserProfile }) {
|
||||
return args.data;
|
||||
}
|
||||
protected async setResPermissions(
|
||||
data: any,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
return data
|
||||
}
|
||||
protected async getRowDto(
|
||||
data: any,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
// 如果没有id,直接返回原数据
|
||||
if (!data?.id) return data;
|
||||
// 如果未启用缓存,直接处理并返回数据
|
||||
if (!this.enableCache) {
|
||||
return this.processDataWithPermissions(data, staff);
|
||||
}
|
||||
const key = this.getRowCacheKey(data.id);
|
||||
try {
|
||||
// 尝试从缓存获取数据
|
||||
const cachedData = await this.getCachedData(key, staff);
|
||||
// 如果缓存命中,直接返回
|
||||
if (cachedData) return cachedData;
|
||||
// 处理数据并缓存
|
||||
const processedData = await this.processDataWithPermissions(data, staff);
|
||||
await redis.set(key, supejson.stringify(processedData));
|
||||
return processedData;
|
||||
} catch (err) {
|
||||
this.logger.error('Error in getRowDto:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCachedData(
|
||||
key: string,
|
||||
staff?: UserProfile
|
||||
): Promise<any | null> {
|
||||
const cachedDataStr = await redis.get(key);
|
||||
if (!cachedDataStr) return null;
|
||||
const cachedData = supejson.parse(cachedDataStr) as any;
|
||||
if (!cachedData?.id) return null;
|
||||
return staff
|
||||
? this.setResPermissions(cachedData, staff)
|
||||
: cachedData;
|
||||
}
|
||||
|
||||
private async processDataWithPermissions(
|
||||
data: any,
|
||||
staff?: UserProfile
|
||||
): Promise<any> {
|
||||
// 处理权限
|
||||
const permData = staff
|
||||
? await this.setResPermissions(data, staff)
|
||||
: data;
|
||||
// 获取关联数据
|
||||
return this.getRowRelation({ data: permData, staff });
|
||||
}
|
||||
|
||||
protected createGetRowsFilters(
|
||||
request: z.infer<typeof RowRequestSchema>,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
const condition = super.createGetRowsFilters(request);
|
||||
if (isFieldCondition(condition)) return {};
|
||||
const baseCondition: LogicalCondition[] = [
|
||||
{
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
condition.AND = [...baseCondition, ...condition.AND];
|
||||
return condition;
|
||||
}
|
||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`${this.tableName}.id AS id`,
|
||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||
];
|
||||
}
|
||||
protected createGroupingRowSelect(
|
||||
request: RowModelRequest,
|
||||
wrapperSql: boolean,
|
||||
): string[] {
|
||||
const colsToSelect = super.createGroupingRowSelect(request, wrapperSql);
|
||||
return colsToSelect.concat([
|
||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||
]);
|
||||
}
|
||||
protected async getRowsSqlWrapper(
|
||||
sql: string,
|
||||
request?: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
): Promise<string> {
|
||||
const groupingSql = SQLBuilder.join([
|
||||
SQLBuilder.select([
|
||||
...this.createGroupingRowSelect(request, true),
|
||||
`${this.tableName}.id AS id`,
|
||||
]),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||
]);
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
if (this.isDoingGroup(request)) {
|
||||
const rowGroupCol = rowGroupCols[groupKeys.length];
|
||||
const groupByField = rowGroupCol?.field?.replace('.', '_');
|
||||
return SQLBuilder.join([
|
||||
SQLBuilder.select([
|
||||
groupByField,
|
||||
...super.createAggSqlForWrapper(request),
|
||||
'COUNT(id) AS child_count',
|
||||
]),
|
||||
SQLBuilder.from(`(${groupingSql})`),
|
||||
SQLBuilder.where({
|
||||
field: 'row_num',
|
||||
value: '1',
|
||||
op: 'equals',
|
||||
}),
|
||||
SQLBuilder.groupBy([groupByField]),
|
||||
SQLBuilder.orderBy(
|
||||
this.getOrderByColumns(request).map((item) => item.replace('.', '_')),
|
||||
),
|
||||
this.getLimitSql(request),
|
||||
]);
|
||||
} else
|
||||
return SQLBuilder.join([
|
||||
SQLBuilder.select(['*']),
|
||||
SQLBuilder.from(`(${sql})`),
|
||||
SQLBuilder.where({
|
||||
field: 'row_num',
|
||||
value: '1',
|
||||
op: 'equals',
|
||||
}),
|
||||
this.getLimitSql(request),
|
||||
]);
|
||||
// return super.getRowsSqlWrapper(sql, request)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
import { Logger } from "@nestjs/common";
|
||||
import { UserProfile, db, getUniqueItems, ObjectWithId, Prisma, RowModelRequest } from "@nicestack/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: ObjectWithId, staff?: UserProfile): Promise<any> {
|
||||
return row;
|
||||
}
|
||||
protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) {
|
||||
return SQLBuilder.join([sql, this.getLimitSql(request)])
|
||||
}
|
||||
protected getLimitSql(request: RowModelRequest) {
|
||||
return SQLBuilder.limit(request.endRow - request.startRow, request.startRow)
|
||||
}
|
||||
abstract createJoinSql(request?: RowModelRequest): string[];
|
||||
async getRows(request: RowModelRequest, staff?: UserProfile): Promise<{ rowCount: number, rowData: any[] }> {
|
||||
try {
|
||||
// this.logger.debug('request', request)
|
||||
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) || [];
|
||||
|
||||
let rowDataDto = await Promise.all(results.map(row => this.getRowDto(row, staff)))
|
||||
|
||||
// if (this.getGroupByColumns(request).length === 0)
|
||||
// rowDataDto = getUniqueItems(rowDataDto, "id")
|
||||
// this.logger.debug('result', results.length, this.getRowCount(request, rowDataDto))
|
||||
return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto };
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error executing getRows:', error);
|
||||
// throw new Error(`Failed to get rows: ${error.message}`);
|
||||
}
|
||||
}
|
||||
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 = []
|
||||
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 = [];
|
||||
|
||||
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 {
|
||||
let 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);
|
||||
|
||||
let 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 = await db.$queryRawUnsafe(SQL);
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
protected buildAggGroupBy(): string[] {
|
||||
return [];
|
||||
}
|
||||
protected buildAggSelect(valueCols: any[]): string[] {
|
||||
return valueCols.map(valueCol =>
|
||||
`${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`
|
||||
);
|
||||
}
|
||||
|
||||
private createGetByIdFilter(id: string): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: id,
|
||||
op: "equals"
|
||||
}
|
||||
}
|
||||
private createGetByIdsFilter(ids: string[]): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: ids,
|
||||
op: "in"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
export interface FieldCondition {
|
||||
field: string;
|
||||
op: OperatorType
|
||||
type?: "text" | "number" | "date";
|
||||
value?: any;
|
||||
valueTo?: any;
|
||||
};
|
||||
export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in';
|
||||
export type LogicalCondition = FieldCondition | {
|
||||
AND?: LogicalCondition[];
|
||||
OR?: LogicalCondition[];
|
||||
};
|
||||
|
||||
export function isFieldCondition(condition: LogicalCondition): condition is FieldCondition {
|
||||
return (condition as FieldCondition).field !== undefined;
|
||||
}
|
||||
function buildCondition(condition: FieldCondition): string {
|
||||
const { field, op, value, type = "text", valueTo } = condition;
|
||||
switch (op) {
|
||||
case 'equals':
|
||||
return `${field} = '${value}'`;
|
||||
case 'notEqual':
|
||||
return `${field} != '${value}'`;
|
||||
case 'contains':
|
||||
return `${field} LIKE '%${value}%'`;
|
||||
case 'startsWith':
|
||||
return `${field} LIKE '${value}%'`;
|
||||
case 'endsWith':
|
||||
return `${field} LIKE '%${value}'`;
|
||||
case 'blank':
|
||||
if (type !== "date")
|
||||
return `(${field} IS NULL OR ${field} = '')`;
|
||||
else
|
||||
return `${field} IS NULL`;
|
||||
case 'notBlank':
|
||||
if (type !== 'date')
|
||||
return `${field} IS NOT NULL AND ${field} != ''`;
|
||||
else
|
||||
return `${field} IS NOT NULL`;
|
||||
case 'greaterThan':
|
||||
return `${field} > '${value}'`;
|
||||
case 'lessThanOrEqual':
|
||||
return `${field} <= '${value}'`;
|
||||
case 'lessThan':
|
||||
return `${field} < '${value}'`;
|
||||
case 'greaterThan':
|
||||
return `${field} > '${value}'`;
|
||||
case 'inRange':
|
||||
return `${field} >= '${value}' AND ${field} <= '${valueTo}'`;
|
||||
case 'in':
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
// Return a condition that is always false if value is empty or an empty array
|
||||
return '1 = 0';
|
||||
}
|
||||
return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`;
|
||||
default:
|
||||
return 'true'; // Default return for unmatched conditions
|
||||
}
|
||||
}
|
||||
function buildLogicalCondition(logicalCondition: LogicalCondition): string {
|
||||
if (isFieldCondition(logicalCondition)) {
|
||||
return buildCondition(logicalCondition);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (logicalCondition.AND && logicalCondition.AND.length > 0) {
|
||||
const andParts = logicalCondition.AND
|
||||
.map(c => buildLogicalCondition(c))
|
||||
.filter(part => part !== ''); // Filter out empty conditions
|
||||
if (andParts.length > 0) {
|
||||
parts.push(`(${andParts.join(' AND ')})`);
|
||||
}
|
||||
}
|
||||
// Process OR conditions
|
||||
if (logicalCondition.OR && logicalCondition.OR.length > 0) {
|
||||
const orParts = logicalCondition.OR
|
||||
.map(c => buildLogicalCondition(c))
|
||||
.filter(part => part !== ''); // Filter out empty conditions
|
||||
if (orParts.length > 0) {
|
||||
parts.push(`(${orParts.join(' OR ')})`);
|
||||
}
|
||||
}
|
||||
// Join AND and OR parts with an 'AND' if both are present
|
||||
return parts.length > 1 ? parts.join(' AND ') : parts[0] || '';
|
||||
}
|
||||
|
||||
export class SQLBuilder {
|
||||
static select(fields: string[], distinctField?: string): string {
|
||||
const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : "";
|
||||
return `SELECT ${distinctClause}${fields.join(", ")}`;
|
||||
}
|
||||
static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string {
|
||||
if (!orderBy) {
|
||||
throw new Error("orderBy 参数不能为空");
|
||||
}
|
||||
|
||||
let partitionClause = '';
|
||||
if (partitionBy) {
|
||||
partitionClause = `PARTITION BY ${partitionBy} `;
|
||||
}
|
||||
|
||||
return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`;
|
||||
}
|
||||
static from(tableName: string): string {
|
||||
return `FROM ${tableName}`;
|
||||
}
|
||||
|
||||
static where(conditions: LogicalCondition): string {
|
||||
const whereClause = buildLogicalCondition(conditions);
|
||||
return whereClause ? `WHERE ${whereClause}` : "";
|
||||
}
|
||||
|
||||
static groupBy(columns: string[]): string {
|
||||
return columns.length ? `GROUP BY ${columns.join(", ")}` : "";
|
||||
}
|
||||
|
||||
static orderBy(columns: string[]): string {
|
||||
return columns.length ? `ORDER BY ${columns.join(", ")}` : "";
|
||||
}
|
||||
|
||||
static limit(pageSize: number, offset: number = 0): string {
|
||||
return `LIMIT ${pageSize + 1} OFFSET ${offset}`;
|
||||
}
|
||||
|
||||
static join(clauses: string[]): string {
|
||||
return clauses.filter(Boolean).join(' ');
|
||||
}
|
||||
static createFilterSql(key: string, item: any): LogicalCondition {
|
||||
const conditionFuncs: Record<string, (item: { values?: any[], dateFrom?: string, dateTo?: string, filter: any, type: OperatorType, filterType: OperatorType }) => LogicalCondition> = {
|
||||
text: (item) => ({ value: item.filter, op: item.type, field: key }),
|
||||
number: (item) => ({ value: item.filter, op: item.type, field: key }),
|
||||
date: (item) => ({ value: item.dateFrom, valueTo: item.dateTo, op: item.type, field: key }),
|
||||
set: (item) => ({ value: item.values, op: "in", field: key })
|
||||
}
|
||||
return conditionFuncs[item.filterType](item)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
SELECT *
|
||||
FROM (
|
||||
SELECT staff.id AS id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY staff.id
|
||||
ORDER BY staff.id
|
||||
) AS row_num,
|
||||
staff.id AS id,
|
||||
staff.username AS username,
|
||||
staff.showname AS showname,
|
||||
staff.avatar AS avatar,
|
||||
staff.officer_id AS officer_id,
|
||||
staff.phone_number AS phone_number,
|
||||
staff.order AS order,
|
||||
staff.enabled AS enabled,
|
||||
dept.name AS dept_name,
|
||||
domain.name AS domain_name
|
||||
FROM staff
|
||||
LEFT JOIN department dept ON staff.dept_id = dept.id
|
||||
LEFT JOIN department domain ON staff.domain_id = domain.id
|
||||
WHERE (
|
||||
staff.deleted_at IS NULL
|
||||
AND enabled = 'Thu Dec 26 2024 11:55:47 GMT+0800 (中国标准时间)'
|
||||
AND staff.domain_id = '784c7583-c7f3-4179-873d-f8195ccf2acf'
|
||||
AND staff.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY "order" asc
|
||||
)
|
||||
WHERE row_num = '1'
|
||||
LIMIT 31 OFFSET 0
|
|
@ -0,0 +1,87 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { DepartmentService } from './department.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nicestack/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,9 +2,12 @@ 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, TrpcService],
|
||||
exports: [DepartmentService, DepartmentRouter]
|
||||
providers: [DepartmentService, DepartmentRouter, DepartmentRowService, TrpcService],
|
||||
exports: [DepartmentService, DepartmentRouter],
|
||||
controllers: [DepartmentController],
|
||||
})
|
||||
export class DepartmentModule { }
|
||||
|
|
|
@ -1,66 +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 { DepartmentSchema, z } from '@nicestack/common';
|
||||
import { DepartmentMethodSchema, Prisma, UpdateOrderSchema } from '@nicestack/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, // inject DepartmentService
|
||||
) {}
|
||||
private readonly departmentService: DepartmentService, // 注入 DepartmentService
|
||||
private readonly departmentRowService: DepartmentRowService
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
// 创建部门
|
||||
create: this.trpc.protectProcedure
|
||||
.input(DepartmentSchema.create) // expect input according to the schema
|
||||
.input(DepartmentCreateArgsSchema) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.create(input);
|
||||
}),
|
||||
// 更新部门
|
||||
update: this.trpc.protectProcedure
|
||||
.input(DepartmentSchema.update) // expect input according to the schema
|
||||
.input(DepartmentUpdateArgsSchema) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.update(input);
|
||||
}),
|
||||
|
||||
delete: this.trpc.protectProcedure
|
||||
.input(DepartmentSchema.delete) // expect input according to the schema
|
||||
// 根据 ID 列表软删除部门
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // 根据 schema 期望输入
|
||||
.mutation(async ({ input }) => {
|
||||
return this.departmentService.delete(input);
|
||||
}),
|
||||
getDepartmentDetails: this.trpc.procedure
|
||||
.input(z.object({ deptId: z.string() })) // expect an object with deptId
|
||||
.query(async ({ input }) => {
|
||||
return this.departmentService.getDepartmentDetails(input.deptId);
|
||||
}),
|
||||
getAllChildDeptIds: this.trpc.procedure
|
||||
.input(z.object({ deptId: z.string() })) // expect an object with deptId
|
||||
.query(async ({ input }) => {
|
||||
return this.departmentService.getAllChildDeptIds(input.deptId);
|
||||
}),
|
||||
getAllParentDeptIds: this.trpc.procedure
|
||||
.input(z.object({ deptId: z.string() })) // expect an object with deptId
|
||||
.query(async ({ input }) => {
|
||||
return this.departmentService.getAllParentDeptIds(input.deptId);
|
||||
}),
|
||||
getChildren: this.trpc.procedure
|
||||
.input(z.object({ parentId: z.string().nullish() }))
|
||||
.query(async ({ input }) => {
|
||||
return this.departmentService.getChildren(input.parentId);
|
||||
}),
|
||||
getDomainDepartments: this.trpc.procedure
|
||||
.input(z.object({ query: z.string().nullish() }))
|
||||
.query(async ({ input }) => {
|
||||
const { query } = input;
|
||||
return this.departmentService.getDomainDepartments(query);
|
||||
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(DepartmentSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.input(DepartmentFindManyArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema
|
||||
.query(async ({ input }) => {
|
||||
return await this.departmentService.findMany(input);
|
||||
}),
|
||||
paginate: this.trpc.procedure
|
||||
.input(DepartmentSchema.paginate) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
// 查询第一个部门
|
||||
findFirst: this.trpc.procedure
|
||||
.input(DepartmentFindFirstArgsSchema) // 假设 StaffMethodSchema.findMany 是根据关键字查找员工的 Zod schema
|
||||
.query(async ({ input }) => {
|
||||
return await this.departmentService.paginate(input);
|
||||
return await this.departmentService.findFirst(input);
|
||||
}),
|
||||
// 获取子部门的简单树结构
|
||||
getChildSimpleTree: this.trpc.procedure
|
||||
.input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => {
|
||||
return await this.departmentService.getChildSimpleTree(input)
|
||||
}),
|
||||
// 获取父部门的简单树结构
|
||||
getParentSimpleTree: this.trpc.procedure
|
||||
.input(DepartmentMethodSchema.getSimpleTree).query(async ({ input }) => {
|
||||
return await this.departmentService.getParentSimpleTree(input)
|
||||
}),
|
||||
// 获取部门行数据
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(DepartmentMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.departmentRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 部门行服务类,继承自 RowCacheService,用于处理部门相关的数据行操作。
|
||||
* 该类提供了生成 SQL 查询字段、构建查询过滤器等功能。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
DepartmentMethodSchema,
|
||||
ObjectType,
|
||||
UserProfile,
|
||||
} from '@nicestack/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;
|
||||
}
|
||||
}
|
|
@ -1,33 +1,90 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, z, DepartmentSchema } from '@nicestack/common';
|
||||
|
||||
import {
|
||||
db,
|
||||
DepartmentMethodSchema,
|
||||
DeptAncestry,
|
||||
getUniqueItems,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
} from '@nicestack/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 {
|
||||
/**
|
||||
* 获取某单位的所有子单位(平铺结构)。
|
||||
* @param deptId - 单位的唯一标识符。
|
||||
* @returns 包含所有子单位的数组,如果未传递deptId则返回undefined。
|
||||
*/
|
||||
async getFlatChildDepts(deptId: string) {
|
||||
if (!deptId) return undefined;
|
||||
|
||||
return await db.deptAncestry.findMany({
|
||||
where: { ancestorId: deptId },
|
||||
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列表
|
||||
let 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列表
|
||||
let descendantDomainIds = domainAncestries.map(
|
||||
(ancestry) => ancestry.descendantId,
|
||||
);
|
||||
// 根据参数决定是否包含祖先域ID
|
||||
if (includeAncestorDomain && ancestorDomainId) {
|
||||
descendantDomainIds.push(ancestorDomainId);
|
||||
}
|
||||
|
||||
return descendantDomainIds;
|
||||
}
|
||||
/**
|
||||
* 获取指定DOM下的对应name的单位
|
||||
* @param domId
|
||||
* @param domainId
|
||||
* @param name
|
||||
* @returns
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
async findByNameInDom(domId: string, name: string) {
|
||||
async findInDomain(domainId: string, name: string) {
|
||||
const subDepts = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: domId,
|
||||
ancestorId: domainId,
|
||||
},
|
||||
include: {
|
||||
descendant: true,
|
||||
|
@ -35,251 +92,38 @@ export class DepartmentService {
|
|||
});
|
||||
const dept = subDepts.find((item) => item.descendant.name === name);
|
||||
|
||||
return dept?.descendant;
|
||||
}
|
||||
/**
|
||||
* 获取某单位的所有父单位(平铺结构)。
|
||||
* @param deptId - 单位的唯一标识符。
|
||||
* @returns 包含所有父单位的数组,如果未传递deptId则返回undefined。
|
||||
*/
|
||||
async getFlatParentDepts(deptId: string) {
|
||||
if (!deptId) return undefined;
|
||||
|
||||
return await db.deptAncestry.findMany({
|
||||
where: { descendantId: deptId },
|
||||
});
|
||||
return dept.descendant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某单位的所有子单位ID。
|
||||
* @param deptId - 单位的唯一标识符。
|
||||
* @returns 包含所有子单位ID的数组。
|
||||
*/
|
||||
async getAllChildDeptIds(deptId: string) {
|
||||
const res = await this.getFlatChildDepts(deptId);
|
||||
return res?.map((dept) => dept.descendantId) || [];
|
||||
private async setDomainId(parentId: string) {
|
||||
const parent = await this.findUnique({ where: { id: parentId } });
|
||||
return parent.isDomain ? parentId : parent.domainId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某单位的所有父单位ID。
|
||||
* @param deptId - 单位的唯一标识符。
|
||||
* @returns 包含所有父单位ID的数组。
|
||||
*/
|
||||
async getAllParentDeptIds(deptId: string) {
|
||||
const res = await this.getFlatParentDepts(deptId);
|
||||
return res?.map((dept) => dept.ancestorId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单位及其直接子单位的详细信息。
|
||||
* @param deptId - 要获取的单位的唯一标识符。
|
||||
* @returns 包含单位详细信息的对象,包括其子单位和员工信息。
|
||||
*/
|
||||
async getDepartmentDetails(deptId: string) {
|
||||
const department = await db.department.findUnique({
|
||||
where: { id: deptId },
|
||||
include: { children: true, deptStaffs: true },
|
||||
});
|
||||
|
||||
const childrenData = await db.deptAncestry.findMany({
|
||||
where: { ancestorId: deptId, relDepth: 1 },
|
||||
include: { descendant: { include: { children: true } } },
|
||||
});
|
||||
|
||||
const children = childrenData.map(({ descendant }) => ({
|
||||
id: descendant.id,
|
||||
name: descendant.name,
|
||||
order: descendant.order,
|
||||
parentId: descendant.parentId,
|
||||
hasChildren: Boolean(descendant.children?.length),
|
||||
childrenCount: descendant.children?.length || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: department?.id,
|
||||
name: department?.name,
|
||||
order: department?.order,
|
||||
parentId: department?.parentId,
|
||||
children,
|
||||
staffs: department?.deptStaffs,
|
||||
hasChildren: !!children.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某单位的所有直接子单位。
|
||||
* @param parentId - 父单位的唯一标识符,如果未传递则获取顶级单位。
|
||||
* @returns 包含所有直接子单位信息的数组。
|
||||
*/
|
||||
async getChildren(parentId?: string) {
|
||||
const departments = await db.department.findMany({
|
||||
where: { parentId: parentId ?? null },
|
||||
include: { children: true, deptStaffs: true },
|
||||
});
|
||||
|
||||
return departments.map((dept) => ({
|
||||
...dept,
|
||||
hasChildren: dept.children.length > 0,
|
||||
staffs: dept.deptStaffs,
|
||||
}));
|
||||
}
|
||||
async paginate(data: z.infer<typeof DepartmentSchema.paginate>) {
|
||||
const { page, pageSize, ids } = data;
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.department.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: { in: ids } }],
|
||||
},
|
||||
include: { deptStaffs: true, parent: true },
|
||||
orderBy: { order: 'asc' },
|
||||
}),
|
||||
db.department.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ id: { in: ids } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { items, totalCount };
|
||||
}
|
||||
async findMany(data: z.infer<typeof DepartmentSchema.findMany>) {
|
||||
const { keyword = '', ids } = data;
|
||||
|
||||
const departments = await db.department.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ name: { contains: keyword! } }, ids ? { id: { in: ids } } : {}],
|
||||
},
|
||||
include: { deptStaffs: true },
|
||||
orderBy: { order: 'asc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return departments.map((dept) => ({
|
||||
...dept,
|
||||
staffs: dept.deptStaffs,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有域内单位,根据查询条件筛选结果。
|
||||
* @param query - 可选的查询条件,用于模糊匹配单位名称。
|
||||
* @returns 包含符合条件的域内单位信息的数组。
|
||||
*/
|
||||
async getDomainDepartments(query?: string) {
|
||||
return await db.department.findMany({
|
||||
where: { isDomain: true, name: { contains: query } },
|
||||
take: 10,
|
||||
});
|
||||
}
|
||||
|
||||
async getDeptIdsByStaffIds(ids: string[]) {
|
||||
const staffs = await db.staff.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
return staffs.map((staff) => staff.deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的单位并管理DeptAncestry关系。
|
||||
* @param data - 用于创建新单位的数据。
|
||||
* @returns 新创建的单位对象。
|
||||
*/
|
||||
async create(data: z.infer<typeof DepartmentSchema.create>) {
|
||||
let newOrder = 0;
|
||||
|
||||
// 确定新单位的顺序
|
||||
const siblingDepartments = await db.department.findMany({
|
||||
where: { parentId: data.parentId ?? null },
|
||||
orderBy: { order: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (siblingDepartments.length > 0) {
|
||||
newOrder = siblingDepartments[0].order + 1;
|
||||
async create(args: Prisma.DepartmentCreateArgs) {
|
||||
if (args.data.parentId) {
|
||||
args.data.domainId = await this.setDomainId(args.data.parentId);
|
||||
}
|
||||
|
||||
// 根据计算的顺序创建新单位
|
||||
const newDepartment = await db.department.create({
|
||||
data: { ...data, order: newOrder },
|
||||
const result = await super.create(args);
|
||||
EventBus.emit('dataChanged', {
|
||||
type: this.objectType,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
|
||||
// 如果存在parentId,则更新DeptAncestry关系
|
||||
if (data.parentId) {
|
||||
const parentAncestries = await db.deptAncestry.findMany({
|
||||
where: { descendantId: data.parentId },
|
||||
orderBy: { relDepth: 'asc' },
|
||||
});
|
||||
|
||||
// 为新单位创建新的祖先记录
|
||||
const newAncestries = parentAncestries.map((ancestry) => ({
|
||||
ancestorId: ancestry.ancestorId,
|
||||
descendantId: newDepartment.id,
|
||||
relDepth: ancestry.relDepth + 1,
|
||||
}));
|
||||
|
||||
newAncestries.push({
|
||||
ancestorId: data.parentId,
|
||||
descendantId: newDepartment.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await db.deptAncestry.createMany({ data: newAncestries });
|
||||
}
|
||||
|
||||
return newDepartment;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新现有单位,并在parentId更改时管理DeptAncestry关系。
|
||||
* @param data - 用于更新现有单位的数据。
|
||||
* @returns 更新后的单位对象。
|
||||
*/
|
||||
async update(data: z.infer<typeof DepartmentSchema.update>) {
|
||||
return await db.$transaction(async (transaction) => {
|
||||
const currentDepartment = await transaction.department.findUnique({
|
||||
where: { id: data.id },
|
||||
});
|
||||
if (!currentDepartment) throw new Error('Department not found');
|
||||
|
||||
const updatedDepartment = await transaction.department.update({
|
||||
where: { id: data.id },
|
||||
data: data,
|
||||
});
|
||||
|
||||
if (data.parentId !== currentDepartment.parentId) {
|
||||
await transaction.deptAncestry.deleteMany({
|
||||
where: { descendantId: data.id },
|
||||
});
|
||||
|
||||
if (data.parentId) {
|
||||
const parentAncestries = await transaction.deptAncestry.findMany({
|
||||
where: { descendantId: data.parentId },
|
||||
});
|
||||
|
||||
const newAncestries = parentAncestries.map((ancestry) => ({
|
||||
ancestorId: ancestry.ancestorId,
|
||||
descendantId: data.id,
|
||||
relDepth: ancestry.relDepth + 1,
|
||||
}));
|
||||
|
||||
newAncestries.push({
|
||||
ancestorId: data.parentId,
|
||||
descendantId: data.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await transaction.deptAncestry.createMany({ data: newAncestries });
|
||||
}
|
||||
}
|
||||
|
||||
return updatedDepartment;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -287,78 +131,208 @@ export class DepartmentService {
|
|||
* @param data - 用于删除现有单位的数据。
|
||||
* @returns 删除的单位对象。
|
||||
*/
|
||||
async delete(data: z.infer<typeof DepartmentSchema.delete>) {
|
||||
const deletedDepartment = await db.department.update({
|
||||
where: { id: data.id },
|
||||
data: { deletedAt: new Date() },
|
||||
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;
|
||||
}
|
||||
|
||||
await db.deptAncestry.deleteMany({
|
||||
where: { OR: [{ ancestorId: data.id }, { descendantId: data.id }] },
|
||||
});
|
||||
return deletedDepartment;
|
||||
}
|
||||
async getStaffsByDeptIds(ids: string[]) {
|
||||
const depts = await db.department.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { deptStaffs: true },
|
||||
});
|
||||
return depts.flatMap((dept) => dept.deptStaffs);
|
||||
}
|
||||
/**
|
||||
* 获取指定部门及其所有子部门的员工。
|
||||
* @param deptIds - 要获取员工ID的部门ID数组。
|
||||
* @returns 包含所有员工ID的数组。
|
||||
*/
|
||||
async getAllStaffsByDepts(deptIds: string[]) {
|
||||
const allDeptIds = new Set(deptIds);
|
||||
for (const deptId of deptIds) {
|
||||
const childDeptIds = await this.getAllChildDeptIds(deptId);
|
||||
childDeptIds.forEach((id) => allDeptIds.add(id));
|
||||
}
|
||||
return await this.getStaffsByDeptIds(Array.from(allDeptIds));
|
||||
async getStaffsInDepts(deptIds: string[]) {
|
||||
const allDeptIds = await this.getDescendantIds(deptIds, true);
|
||||
return await getStaffsByDeptIds(Array.from(allDeptIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门名称和域ID获取部门ID。
|
||||
*
|
||||
* @param {string} name - 部门名称。
|
||||
* @param {string} domainId - 域标识符。
|
||||
* @returns {Promise<string | null>} - 如果找到则返回部门ID,否则返回null。
|
||||
*/
|
||||
async getDeptIdByName(name: string, domainId: string): Promise<string | null> {
|
||||
const dept = await db.department.findFirst({
|
||||
where: {
|
||||
name,
|
||||
ancestors: {
|
||||
some: {
|
||||
ancestorId: domainId
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return dept ? dept.id : null;
|
||||
async getStaffIdsInDepts(deptIds: string[]) {
|
||||
const result = await this.getStaffsInDepts(deptIds);
|
||||
return result.map((s) => s.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门名称列表和域ID获取多个部门的ID。
|
||||
*
|
||||
* @param {string[]} names - 部门名称列表。
|
||||
* @param {string} domainId - 域标识符。
|
||||
* @returns {Promise<Record<string, string | null>>} - 一个从部门名称到对应ID或null的记录。
|
||||
*
|
||||
* @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>> {
|
||||
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> = {};
|
||||
|
||||
// 遍历每个部门名称并获取对应的部门ID
|
||||
// 遍历传入的部门名称列表
|
||||
for (const name of names) {
|
||||
// 使用之前定义的函数根据名称获取部门ID
|
||||
const deptId = await this.getDeptIdByName(name, domainId);
|
||||
result[name] = deptId;
|
||||
// 从Map中获取部门ID,如果不存在则返回null
|
||||
result[name] = deptMap.get(name) || null;
|
||||
}
|
||||
|
||||
// 返回最终的结果对象
|
||||
return result;
|
||||
}
|
||||
|
||||
async getChildSimpleTree(
|
||||
data: z.infer<typeof DepartmentMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
const { domain, deptIds, rootId } = data;
|
||||
// 提取非空 deptIds
|
||||
const validDeptIds = deptIds?.filter((id) => id !== null) ?? [];
|
||||
const hasNullDeptId = deptIds?.includes(null) ?? false;
|
||||
const [childrenData, selfData] = await Promise.all([
|
||||
db.deptAncestry.findMany({
|
||||
where: {
|
||||
...(deptIds && {
|
||||
OR: [
|
||||
...(validDeptIds.length
|
||||
? [{ ancestorId: { in: validDeptIds } }]
|
||||
: []),
|
||||
...(hasNullDeptId ? [{ ancestorId: null }] : []),
|
||||
],
|
||||
}),
|
||||
ancestorId: rootId,
|
||||
relDepth: 1,
|
||||
descendant: { isDomain: domain },
|
||||
},
|
||||
include: {
|
||||
descendant: { include: { children: true, deptStaffs: true } },
|
||||
},
|
||||
orderBy: { descendant: { order: 'asc' } },
|
||||
}),
|
||||
deptIds
|
||||
? db.department.findMany({
|
||||
where: {
|
||||
...(deptIds && {
|
||||
OR: [
|
||||
...(validDeptIds.length
|
||||
? [{ id: { in: validDeptIds } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
isDomain: domain,
|
||||
},
|
||||
include: { children: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const children = childrenData
|
||||
.map(({ descendant }) => descendant)
|
||||
.filter(Boolean)
|
||||
.map(mapToDeptSimpleTree);
|
||||
const selfItems = selfData.map(mapToDeptSimpleTree);
|
||||
return getUniqueItems([...children, ...selfItems], 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父级部门简单树结构的异步方法
|
||||
*
|
||||
* @param data - 包含部门ID、域和根ID的输入参数
|
||||
* @returns 返回唯一的部门简单树结构数组
|
||||
*
|
||||
* 方法整体设计思路:
|
||||
* 1. 并行查询父级部门ancestry和自身部门数据
|
||||
* 2. 查询根节点的直接子节点
|
||||
* 3. 通过自定义过滤函数筛选超出根节点层级的祖先节点
|
||||
* 4. 将查询结果映射为简单树结构
|
||||
* 5. 去重并返回最终结果
|
||||
*/
|
||||
async getParentSimpleTree(
|
||||
data: z.infer<typeof DepartmentMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
// 解构输入参数
|
||||
const { deptIds, domain, rootId } = data;
|
||||
// 并行查询父级部门ancestry和自身部门数据
|
||||
// 使用Promise.all提高查询效率,减少等待时间
|
||||
const [parentData, selfData] = await Promise.all([
|
||||
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
|
||||
db.deptAncestry.findMany({
|
||||
where: {
|
||||
descendantId: { in: deptIds }, // 查询条件:descendant在给定的部门ID列表中
|
||||
ancestor: { isDomain: domain }, // 限定域
|
||||
},
|
||||
include: {
|
||||
ancestor: {
|
||||
include: {
|
||||
children: true, // 包含子节点信息
|
||||
parent: true, // 包含父节点信息
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
|
||||
}),
|
||||
|
||||
// 查询自身部门数据
|
||||
db.department.findMany({
|
||||
where: { id: { in: deptIds }, isDomain: domain },
|
||||
include: { children: true }, // 包含子节点信息
|
||||
orderBy: { order: 'asc' }, // 按顺序升序排序
|
||||
}),
|
||||
]);
|
||||
|
||||
// 查询根节点的直接子节点
|
||||
const rootChildren = await db.deptAncestry.findMany({
|
||||
where: {
|
||||
ancestorId: rootId, // 祖先ID为根ID
|
||||
descendant: { isDomain: domain }, // 限定域
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 判断祖先节点是否超出根节点层级的函数
|
||||
*
|
||||
* @param ancestor - 祖先节点
|
||||
* @returns 是否超出根节点层级
|
||||
*/
|
||||
const isDirectDescendantOfRoot = (ancestor: DeptAncestry): boolean => {
|
||||
return (
|
||||
rootChildren.findIndex(
|
||||
(child) => child.descendantId === ancestor.ancestorId,
|
||||
) !== -1
|
||||
);
|
||||
};
|
||||
|
||||
// 处理父级节点:过滤并映射为简单树结构
|
||||
const parents = parentData
|
||||
.map(({ ancestor }) => ancestor) // 提取祖先节点
|
||||
.filter(
|
||||
(ancestor) => ancestor && isDirectDescendantOfRoot(ancestor as any),
|
||||
) // 过滤有效且超出根节点层级的节点
|
||||
.map(mapToDeptSimpleTree); // 映射为简单树结构
|
||||
|
||||
// 处理自身节点:映射为简单树结构
|
||||
const selfItems = selfData.map(mapToDeptSimpleTree);
|
||||
|
||||
// 合并并去重父级和自身节点,返回唯一项
|
||||
return getUniqueItems([...parents, ...selfItems], 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import { UserProfile, db, DeptSimpleTreeNode, TreeDataNode } from "@nicestack/common";
|
||||
|
||||
/**
|
||||
* 将部门数据映射为DeptSimpleTreeNode结构
|
||||
* @param department 部门数据对象
|
||||
* @returns 返回格式化后的DeptSimpleTreeNode对象
|
||||
* 数据结构说明:
|
||||
* - id: 部门唯一标识
|
||||
* - key: 部门唯一标识,通常用于React中的key属性
|
||||
* - value: 部门唯一标识,通常用于表单值
|
||||
* - title: 部门名称
|
||||
* - order: 部门排序值
|
||||
* - pId: 父部门ID,用于构建树形结构
|
||||
* - isLeaf: 是否为叶子节点,即该部门是否没有子部门
|
||||
* - hasStaff: 该部门是否包含员工
|
||||
*/
|
||||
export function mapToDeptSimpleTree(department: any): DeptSimpleTreeNode {
|
||||
return {
|
||||
id: department.id,
|
||||
key: department.id,
|
||||
value: department.id,
|
||||
title: department.name,
|
||||
order: department.order,
|
||||
pId: department.parentId,
|
||||
isLeaf: !Boolean(department.children?.length),
|
||||
hasStaff: department?.deptStaffs?.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门ID列表获取相关员工信息
|
||||
* @param ids 部门ID列表
|
||||
* @returns 返回与指定部门相关的员工ID列表
|
||||
* 算法说明:
|
||||
* - 使用数据库查询方法findMany,根据部门ID列表查询相关部门的员工信息
|
||||
* - 使用flatMap将查询结果扁平化,提取所有员工ID
|
||||
*/
|
||||
export async function getStaffsByDeptIds(ids: string[]) {
|
||||
const depts = await db.department.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
deptStaffs: {
|
||||
select: { id: true }
|
||||
}
|
||||
},
|
||||
});
|
||||
return depts.flatMap((dept) => dept.deptStaffs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取唯一的员工ID列表
|
||||
* @param params 参数对象,包含部门ID列表、员工ID列表和员工信息
|
||||
* @returns 返回唯一的员工ID列表
|
||||
* 算法说明:
|
||||
* - 根据部门ID列表获取相关员工ID
|
||||
* - 将部门员工ID与传入的员工ID列表合并,并使用Set去重
|
||||
* - 如果传入了员工信息,则从结果中移除该员工的ID
|
||||
* - 最终返回去重后的员工ID列表
|
||||
*/
|
||||
export async function extractUniqueStaffIds(params: { deptIds?: string[], staffIds?: string[], staff?: UserProfile }): Promise<string[]> {
|
||||
const { deptIds, staff, staffIds } = params;
|
||||
const deptStaffs = await getStaffsByDeptIds(deptIds);
|
||||
const result = new Set(deptStaffs.map(item => item.id).concat(staffIds));
|
||||
if (staff) {
|
||||
result.delete(staff.id);
|
||||
}
|
||||
return Array.from(result);
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { MessageService } from './message.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db, VisitType } from '@nicestack/common';
|
||||
|
||||
@Controller('message')
|
||||
export class MessageController {
|
||||
constructor(private readonly messageService: MessageService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-last-one')
|
||||
async findLastOne(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-unreaded')
|
||||
async findUnreaded(@Query('staff-id') staffId: string) {
|
||||
try {
|
||||
const result = await db.message.findMany({
|
||||
where: {
|
||||
visits: {
|
||||
none: {
|
||||
id: staffId,
|
||||
visitType: 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,
|
||||
visitType: VisitType.READED
|
||||
},
|
||||
},
|
||||
receivers: {
|
||||
some: {
|
||||
id: staffId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
url: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MessageService } from './message.service';
|
||||
import { MessageRouter } from './message.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { MessageController } from './message.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [MessageService, MessageRouter, TrpcService],
|
||||
exports: [MessageService, MessageRouter],
|
||||
controllers: [MessageController],
|
||||
})
|
||||
export class MessageModule { }
|
|
@ -0,0 +1,39 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { MessageService } from './message.service';
|
||||
import { ChangedRows, Prisma } from '@nicestack/common';
|
||||
import { z, ZodType } from 'zod';
|
||||
const MessageUncheckedCreateInputSchema: ZodType<Prisma.MessageUncheckedCreateInput> = z.any()
|
||||
const MessageWhereInputSchema: ZodType<Prisma.MessageWhereInput> = z.any()
|
||||
const MessageSelectSchema: ZodType<Prisma.MessageSelect> = z.any()
|
||||
@Injectable()
|
||||
export class MessageRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly messageService: MessageService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure
|
||||
.input(MessageUncheckedCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.create({ data: input }, { staff });
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: MessageWhereInputSchema.nullish(),
|
||||
select: MessageSelectSchema.nullish()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.findManyWithCursor(input, staff);
|
||||
}),
|
||||
getUnreadCount: this.trpc.protectProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.messageService.getUnreadCount(staff);
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { UserProfile, db, Prisma, VisitType, ObjectType } from '@nicestack/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,
|
||||
visitType: VisitType.READED
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { Message, UserProfile, VisitType, db } from "@nicestack/common"
|
||||
export async function setMessageRelation(
|
||||
data: Message,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
|
||||
const readed =
|
||||
(await db.visit.count({
|
||||
where: {
|
||||
messageId: data.id,
|
||||
visitType: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
|
||||
|
||||
Object.assign(data, {
|
||||
readed
|
||||
})
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { PostService } from './post.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nicestack/common';
|
||||
|
||||
@Controller('post')
|
||||
export class PostController {
|
||||
constructor(private readonly postService: PostService) { }
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-last-one')
|
||||
async findLastOne(@Query('trouble-id') troubleId: string) {
|
||||
try {
|
||||
const result = await this.postService.findFirst({
|
||||
where: { referenceId: troubleId },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('find-all')
|
||||
async findAll(@Query('trouble-id') troubleId: string) {
|
||||
try {
|
||||
const result = await db.post.findMany({
|
||||
where: {
|
||||
OR: [{ referenceId: troubleId }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
title: true,
|
||||
content: true,
|
||||
attachments: true,
|
||||
type: true,
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
showname: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('count')
|
||||
async count(@Query('trouble-id') troubleId: string) {
|
||||
try {
|
||||
const result = await db.post.count({
|
||||
where: {
|
||||
OR: [{ referenceId: troubleId }],
|
||||
},
|
||||
});
|
||||
return {
|
||||
data: result,
|
||||
errmsg: 'success',
|
||||
errno: 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {},
|
||||
errmsg: (e as any)?.message || 'error',
|
||||
errno: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
|
||||
import { QueueModule } from '@server/queue/queue.module';
|
||||
import { MessageModule } from '../message/message.module';
|
||||
import { PostRouter } from './post.router';
|
||||
import { PostController } from './post.controller';
|
||||
import { PostService } from './post.service';
|
||||
import { RoleMapModule } from '../rbac/rbac.module';
|
||||
|
||||
@Module({
|
||||
imports: [QueueModule, RoleMapModule, MessageModule],
|
||||
providers: [PostService, PostRouter, TrpcService, DepartmentService],
|
||||
exports: [PostRouter, PostService],
|
||||
controllers: [PostController],
|
||||
})
|
||||
export class PostModule {}
|
|
@ -0,0 +1,77 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { ChangedRows, ObjectType, Prisma } from '@nicestack/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 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);
|
||||
}),
|
||||
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);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
Prisma,
|
||||
UserProfile,
|
||||
VisitType,
|
||||
Post,
|
||||
PostType,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
ObjectType,
|
||||
} from '@nicestack/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';
|
||||
|
||||
@Injectable()
|
||||
export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||
constructor(
|
||||
private readonly messageService: MessageService,
|
||||
private readonly departmentService: DepartmentService,
|
||||
) {
|
||||
super(db, ObjectType.POST);
|
||||
}
|
||||
async create(
|
||||
args: Prisma.PostCreateArgs,
|
||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||
) {
|
||||
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;
|
||||
return super.update(args);
|
||||
}
|
||||
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
|
||||
args.where.OR = await this.preFilter(args.where.OR, staff);
|
||||
|
||||
// console.log(`findwithcursor_post ${JSON.stringify(args.where)}`)
|
||||
return this.wrapResult(super.findManyWithCursor(args), async (result) => {
|
||||
let { 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) {
|
||||
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 && {
|
||||
watchStaffs: {
|
||||
some: {
|
||||
id: staff.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
deptId && {
|
||||
watchDepts: {
|
||||
some: {
|
||||
id: {
|
||||
in: parentDeptIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
watchStaffs: {
|
||||
none: {}, // 匹配 watchStaffs 为空
|
||||
},
|
||||
},
|
||||
{
|
||||
watchDepts: {
|
||||
none: {}, // 匹配 watchDepts 为空
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
if (orCondition?.length > 0) return orCondition;
|
||||
return undefined;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { db, Post, PostType, UserProfile, VisitType } from "@nicestack/common";
|
||||
import { getTroubleWithRelation } from "../trouble/utils";
|
||||
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,
|
||||
visitType: VisitType.READED,
|
||||
visitorId: staff?.id,
|
||||
},
|
||||
})) > 0;
|
||||
const readedCount = await db.visit.count({
|
||||
where: {
|
||||
postId: data.id,
|
||||
visitType: VisitType.READED,
|
||||
},
|
||||
});
|
||||
const trouble = await getTroubleWithRelation(data.referenceId, staff)
|
||||
Object.assign(data, {
|
||||
readed,
|
||||
readedCount,
|
||||
limitedComments,
|
||||
commentsCount,
|
||||
trouble
|
||||
})
|
||||
|
||||
}
|
10
apps/server/src/rbac/rbac.module.ts → apps/server/src/models/rbac/rbac.module.ts
Normal file → Executable file
10
apps/server/src/rbac/rbac.module.ts → apps/server/src/models/rbac/rbac.module.ts
Normal file → Executable file
|
@ -4,13 +4,11 @@ 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 '@server/models/department/department.module';
|
||||
import { RolePermsService } from './roleperms.service';
|
||||
import { RelationService } from '@server/relation/relation.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule],
|
||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter, RolePermsService, RelationService],
|
||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter, RolePermsService]
|
||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter],
|
||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter]
|
||||
})
|
||||
export class RbacModule { }
|
||||
export class RoleMapModule { }
|
|
@ -0,0 +1,44 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { TrpcService } from "@server/trpc/trpc.service";
|
||||
import { RoleService } from "./role.service";
|
||||
import { RoleMethodSchema } from "@nicestack/common";
|
||||
import { z } from "zod";
|
||||
|
||||
@Injectable()
|
||||
export class RoleRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleService: RoleService
|
||||
) { }
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure.input(RoleMethodSchema.create).mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.create(input);
|
||||
}),
|
||||
deleteMany: this.trpc.protectProcedure.input(RoleMethodSchema.deleteMany).mutation(async ({ input }) => {
|
||||
return await this.roleService.deleteMany(input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure.input(RoleMethodSchema.update).mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.update(input);
|
||||
}),
|
||||
paginate: this.trpc.protectProcedure.input(RoleMethodSchema.paginate).query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.paginate(input);
|
||||
}),
|
||||
findById: this.trpc.protectProcedure
|
||||
.input(z.object({ id: z.string().nullish() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.findById(input.id);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(RoleMethodSchema.findMany) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleService.findMany(input);
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, RoleMethodSchema, RowModelRequest, UserProfile, RowRequestSchema, ObjectWithId, ObjectType } from "@nicestack/common";
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { RowModelService } from '../base/row-model.service';
|
||||
import { isFieldCondition, LogicalCondition } from '../base/sql-builder';
|
||||
import { z } from 'zod';
|
||||
@Injectable()
|
||||
export class RoleService extends RowModelService {
|
||||
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: ObjectWithId, 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 [];
|
||||
}
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService
|
||||
) {
|
||||
super("role")
|
||||
}
|
||||
/**
|
||||
* 创建角色
|
||||
* @param data 包含创建角色所需信息的数据
|
||||
* @returns 创建的角色
|
||||
*/
|
||||
async create(data: z.infer<typeof RoleMethodSchema.create>) {
|
||||
const result = await db.role.create({ data })
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.ROLE,
|
||||
operation: CrudOperation.CREATED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
}
|
||||
async findById(id: string) {
|
||||
return await db.role.findUnique({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 更新角色
|
||||
* @param data 包含更新角色所需信息的数据
|
||||
* @returns 更新后的角色
|
||||
*/
|
||||
async update(data: z.infer<typeof RoleMethodSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
// 开启事务
|
||||
const result = await db.role.update({
|
||||
where: { id },
|
||||
data: { ...others }
|
||||
});
|
||||
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.ROLE,
|
||||
operation: CrudOperation.UPDATED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* 批量删除角色
|
||||
* @param data 包含要删除的角色ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async deleteMany(data: z.infer<typeof RoleMethodSchema.deleteMany>) {
|
||||
const { ids } = data;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.'
|
||||
});
|
||||
}
|
||||
// 开启事务
|
||||
const result = await db.$transaction(async (prisma) => {
|
||||
await prisma.roleMap.deleteMany({
|
||||
where: {
|
||||
roleId: {
|
||||
in: ids
|
||||
}
|
||||
}
|
||||
});
|
||||
const deletedRoles = await prisma.role.deleteMany({
|
||||
where: {
|
||||
id: { in: ids }
|
||||
}
|
||||
});
|
||||
return { success: true, count: deletedRoles.count };
|
||||
});
|
||||
EventBus.emit('dataChanged', {
|
||||
type: ObjectType.ROLE,
|
||||
operation: CrudOperation.DELETED,
|
||||
data: result,
|
||||
});
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* 分页获取角色
|
||||
* @param data 包含分页信息的数据
|
||||
* @returns 分页结果,包含角色列表和总数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof RoleMethodSchema.paginate>) {
|
||||
const { page, pageSize } = data;
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.role.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { name: "asc" },
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
roleMaps: true,
|
||||
}
|
||||
}),
|
||||
db.role.count({ where: { deletedAt: null } }),
|
||||
]);
|
||||
const result = { items, totalCount };
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 根据关键字查找多个角色
|
||||
* @param data 包含关键字的数据
|
||||
* @returns 查找到的角色列表
|
||||
*/
|
||||
async findMany(data: z.infer<typeof RoleMethodSchema.findMany>) {
|
||||
const { keyword = '' } = data
|
||||
return await db.role.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: keyword
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 10
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import {
|
||||
ChangedRows,
|
||||
ObjectType,
|
||||
RoleMapMethodSchema,
|
||||
} from '@nicestack/common';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
|
||||
@Injectable()
|
||||
export class RoleMapRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleMapService: RoleMapService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
deleteAllRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.deleteWithObject)
|
||||
.mutation(({ input }) =>
|
||||
this.roleMapService.deleteAllRolesForObject(input),
|
||||
),
|
||||
setRoleForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.create)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
|
||||
setRoleForObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRoleForObjects)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObjects(input)),
|
||||
addRoleForObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRoleForObjects)
|
||||
.mutation(({ input }) => this.roleMapService.addRoleForObjects(input)),
|
||||
setRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.setRolesForObject)
|
||||
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)),
|
||||
|
||||
getPermsForObject: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getPermsForObject)
|
||||
.query(({ input }) => this.roleMapService.getPermsForObject(input)),
|
||||
deleteMany: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.deleteMany) // Assuming RoleMapMethodSchema.deleteMany is the Zod schema for batch deleting staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.roleMapService.deleteMany(input);
|
||||
}),
|
||||
|
||||
paginate: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.paginate) // Define the input schema for pagination
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.paginate(input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.update)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleMapService.update(input);
|
||||
}),
|
||||
getRoleMapDetail: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getRoleMapDetail)
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.getRoleMapDetail(input);
|
||||
}),
|
||||
getRows: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleMapService.getRows(input, staff);
|
||||
}),
|
||||
getStaffsNotMap: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getStaffsNotMap)
|
||||
.query(async ({ input }) => {
|
||||
return this.roleMapService.getStaffsNotMap(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,317 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
RoleMapMethodSchema,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
RowModelRequest,
|
||||
UserProfile,
|
||||
ObjectWithId,
|
||||
} from '@nicestack/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: ObjectWithId,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
if (!row.id) return row;
|
||||
return row;
|
||||
}
|
||||
/**
|
||||
* 删除某对象的所有角色
|
||||
* @param data 包含对象ID的数据
|
||||
* @returns 删除结果
|
||||
*/
|
||||
async deleteAllRolesForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.deleteWithObject>,
|
||||
) {
|
||||
const { objectId } = data;
|
||||
return await db.roleMap.deleteMany({
|
||||
where: {
|
||||
objectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 为某对象设置一个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射
|
||||
*/
|
||||
async setRoleForObject(data: z.infer<typeof RoleMapMethodSchema.create>) {
|
||||
return await db.roleMap.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 批量为多个对象创建角色映射
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async setRoleForObjects(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRoleForObjects>,
|
||||
) {
|
||||
const { domainId, roleId, objectIds, objectType } = data;
|
||||
const roleMaps = objectIds.map((id) => ({
|
||||
domainId,
|
||||
objectId: id,
|
||||
roleId,
|
||||
objectType,
|
||||
}));
|
||||
// 开启事务
|
||||
const result = await db.$transaction(async (prisma) => {
|
||||
// 首先,删除现有的角色映射
|
||||
await prisma.roleMap.deleteMany({
|
||||
where: {
|
||||
domainId,
|
||||
roleId,
|
||||
objectType,
|
||||
},
|
||||
});
|
||||
// 然后,创建新的角色映射
|
||||
return await prisma.roleMap.createManyAndReturn({
|
||||
data: roleMaps,
|
||||
});
|
||||
});
|
||||
const wrapResult = Promise.all(result.map(async item => {
|
||||
const staff = await db.staff.findMany({
|
||||
include: { department: true },
|
||||
where: {
|
||||
id: item.objectId
|
||||
}
|
||||
})
|
||||
return { ...item, staff }
|
||||
}))
|
||||
return wrapResult;
|
||||
}
|
||||
async addRoleForObjects(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRoleForObjects>,
|
||||
) {
|
||||
const { domainId, roleId, objectIds, objectType } = data;
|
||||
const objects = await db.roleMap.findMany({
|
||||
where: { domainId, roleId, objectType },
|
||||
});
|
||||
data.objectIds = Array.from(
|
||||
new Set([...objectIds, ...objects.map((obj) => obj.objectId)]),
|
||||
);
|
||||
const result = this.setRoleForObjects(data);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 为某对象设置多个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async setRolesForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.setRolesForObject>,
|
||||
) {
|
||||
const { domainId, objectId, roleIds, objectType } = data;
|
||||
const roleMaps = roleIds.map((id) => ({
|
||||
domainId,
|
||||
objectId,
|
||||
roleId: id,
|
||||
objectType,
|
||||
}));
|
||||
|
||||
return await db.roleMap.createMany({ data: roleMaps });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某对象的权限
|
||||
* @param data 包含域ID、部门ID和对象ID的数据
|
||||
* @returns 用户角色的权限列表
|
||||
*/
|
||||
async getPermsForObject(
|
||||
data: z.infer<typeof RoleMapMethodSchema.getPermsForObject>,
|
||||
) {
|
||||
const { domainId, deptId, staffId } = data;
|
||||
// Get all ancestor department IDs if deptId is provided.
|
||||
const ancestorDeptIds = deptId
|
||||
? await this.departmentService.getAncestorIds(deptId)
|
||||
: [];
|
||||
// Define a common filter for querying roles.
|
||||
const objectFilters: Prisma.RoleMapWhereInput[] = [
|
||||
{ objectId: staffId, objectType: ObjectType.STAFF },
|
||||
...(deptId || ancestorDeptIds.length > 0
|
||||
? [
|
||||
{
|
||||
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
||||
objectType: ObjectType.DEPARTMENT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
// Helper function to fetch roles based on domain ID.
|
||||
const fetchRoles = async (domainId: string) => {
|
||||
return db.roleMap.findMany({
|
||||
where: {
|
||||
AND: {
|
||||
domainId,
|
||||
OR: objectFilters,
|
||||
},
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
};
|
||||
// Fetch roles with and without specific domain IDs.
|
||||
const [nullDomainRoles, userRoles] = await Promise.all([
|
||||
fetchRoles(null),
|
||||
fetchRoles(domainId),
|
||||
]);
|
||||
// Extract permissions from roles and return them.
|
||||
return [...userRoles, ...nullDomainRoles].flatMap(
|
||||
({ role }) => role.permissions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除角色映射
|
||||
* @param data 包含要删除的角色映射ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async deleteMany(data: z.infer<typeof RoleMapMethodSchema.deleteMany>) {
|
||||
const { ids } = data;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.',
|
||||
});
|
||||
}
|
||||
const rowData = await this.getRowByIds({ ids, extraCondition: {} });
|
||||
await db.roleMap.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
return rowData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取角色映射
|
||||
* @param data 包含分页信息的数据
|
||||
* @returns 分页结果,包含角色映射列表和总数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof RoleMapMethodSchema.paginate>) {
|
||||
const { page, pageSize, domainId, roleId } = data;
|
||||
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.roleMap.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
where: { domainId, roleId },
|
||||
}),
|
||||
db.roleMap.count({
|
||||
where: { domainId, roleId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
|
||||
return { items, totalCount };
|
||||
}
|
||||
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) {
|
||||
const { domainId, roleId } = data;
|
||||
let staffs = await db.staff.findMany({
|
||||
where: {
|
||||
domainId,
|
||||
},
|
||||
});
|
||||
const roleMaps = await db.roleMap.findMany({
|
||||
where: {
|
||||
domainId,
|
||||
roleId,
|
||||
objectType: ObjectType.STAFF,
|
||||
},
|
||||
});
|
||||
staffs = staffs.filter(
|
||||
(staff) =>
|
||||
roleMaps.findIndex((roleMap) => roleMap.objectId === staff.id) === -1,
|
||||
);
|
||||
return staffs;
|
||||
}
|
||||
/**
|
||||
* 更新角色映射
|
||||
* @param data 包含更新信息的数据
|
||||
* @returns 更新后的角色映射
|
||||
*/
|
||||
async update(data: z.infer<typeof RoleMapMethodSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
|
||||
const updatedRoleMap = await db.roleMap.update({
|
||||
where: { id },
|
||||
data: { ...others },
|
||||
});
|
||||
return updatedRoleMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色映射详情
|
||||
* @param data 包含角色ID和域ID的数据
|
||||
* @returns 角色映射详情,包含部门ID和员工ID列表
|
||||
*/
|
||||
async getRoleMapDetail(data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>) {
|
||||
const { roleId, domainId } = data;
|
||||
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
|
||||
|
||||
const deptIds = res
|
||||
.filter((item) => item.objectType === ObjectType.DEPARTMENT)
|
||||
.map((item) => item.objectId);
|
||||
const staffIds = res
|
||||
.filter((item) => item.objectType === ObjectType.STAFF)
|
||||
.map((item) => item.objectId);
|
||||
|
||||
return { deptIds, staffIds };
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { StaffService } from './staff.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nicestack/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StaffRouter } from './staff.router';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffRouter } from './staff.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffRowService } from './staff.row.service';
|
||||
|
||||
@Module({
|
||||
providers: [StaffRouter, StaffService, TrpcService, DepartmentService],
|
||||
exports: [StaffRouter, StaffService]
|
||||
imports: [DepartmentModule],
|
||||
providers: [StaffService, StaffRouter, TrpcService, StaffRowService],
|
||||
exports: [StaffService, StaffRouter, StaffRowService],
|
||||
controllers: [StaffController],
|
||||
})
|
||||
export class StaffModule { }
|
||||
|
|
|
@ -1,48 +1,79 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { StaffService } from './staff.service'; // Adjust the import path as necessary
|
||||
import { z, StaffSchema } from '@nicestack/common';
|
||||
|
||||
import { StaffMethodSchema, Prisma, UpdateOrderSchema } from '@nicestack/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.PostUpdateInput> = 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(StaffSchema.create) // Assuming StaffSchema.create is the Zod schema for creating staff
|
||||
.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(StaffSchema.update) // Assuming StaffSchema.update is the Zod schema for updating staff
|
||||
.input(StaffUpdateArgsSchema) // Assuming StaffMethodSchema.update is the Zod schema for updating staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.staffService.update(input);
|
||||
}),
|
||||
|
||||
batchDelete: this.trpc.procedure
|
||||
.input(StaffSchema.batchDelete) // Assuming StaffSchema.batchDelete is the Zod schema for batch deleting staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.staffService.batchDelete(input);
|
||||
updateUserDomain: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domainId: z.string()
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return await this.staffService.updateUserDomain(input, ctx.staff);
|
||||
}),
|
||||
|
||||
paginate: this.trpc.procedure
|
||||
.input(StaffSchema.paginate) // Define the input schema for pagination
|
||||
.query(async ({ input }) => {
|
||||
return await this.staffService.paginate(input);
|
||||
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(StaffSchema.findByDept)
|
||||
.input(StaffMethodSchema.findByDept)
|
||||
.query(async ({ input }) => {
|
||||
return await this.staffService.findByDept(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.input(StaffFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.staffService.findMany(input);
|
||||
})
|
||||
}),
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(StaffMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.staffRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
findFirst: this.trpc.protectProcedure
|
||||
.input(StaffFindFirstArgsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.staffService.findFirst(input);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => {
|
||||
return this.staffService.updateOrder(input)
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
db,
|
||||
ObjectType,
|
||||
StaffMethodSchema,
|
||||
UserProfile,
|
||||
RolePerms,
|
||||
ResPerm,
|
||||
Staff,
|
||||
RowModelRequest,
|
||||
} from '@nicestack/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 };
|
||||
}
|
||||
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { StaffService } from './staff.service';
|
||||
|
||||
describe('StaffService', () => {
|
||||
let service: StaffService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [StaffService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<StaffService>(StaffService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
|
@ -1,178 +1,180 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, ObjectType, Staff, StaffSchema, z } from '@nicestack/common';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import {
|
||||
db,
|
||||
StaffMethodSchema,
|
||||
ObjectType,
|
||||
UserProfile,
|
||||
Prisma,
|
||||
} from '@nicestack/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 {
|
||||
constructor(private readonly departmentService: DepartmentService) { }
|
||||
export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
||||
|
||||
/**
|
||||
* 获取某一单位下所有staff的记录
|
||||
* @param deptId 单位的id
|
||||
* @returns 查到的staff记录
|
||||
*/
|
||||
async findByDept(data: z.infer<typeof StaffSchema.findByDept>) {
|
||||
const { deptId, domainId } = data;
|
||||
const childDepts = await this.departmentService.getAllChildDeptIds(deptId);
|
||||
const result = await db.staff.findMany({
|
||||
where: {
|
||||
deptId: { in: [...childDepts, deptId] },
|
||||
domainId,
|
||||
},
|
||||
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 } })
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 创建新的员工记录
|
||||
* @param data 员工创建信息
|
||||
* @returns 新创建的员工记录
|
||||
*/
|
||||
async create(data: z.infer<typeof StaffSchema.create>) {
|
||||
const { ...others } = data;
|
||||
|
||||
try {
|
||||
return await db.$transaction(async (transaction) => {
|
||||
// 获取当前最大order值
|
||||
const maxOrder = await transaction.staff.aggregate({
|
||||
_max: { order: true },
|
||||
});
|
||||
// 新员工的order值比现有最大order值大1
|
||||
const newOrder = (maxOrder._max.order ?? -1) + 1;
|
||||
// 创建新员工记录
|
||||
const newStaff = await transaction.staff.create({
|
||||
data: { ...others, order: newOrder },
|
||||
include: { domain: true, department: true },
|
||||
});
|
||||
return newStaff;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create staff:', error);
|
||||
throw error;
|
||||
if (count > 0) {
|
||||
throw new Error(errorMsg(data[field]));
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 更新员工记录
|
||||
* @param data 包含id和其他更新字段的对象
|
||||
* @returns 更新后的员工记录
|
||||
*/
|
||||
async update(data: z.infer<typeof StaffSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
try {
|
||||
return await db.$transaction(async (transaction) => {
|
||||
// 更新员工记录
|
||||
const updatedStaff = await transaction.staff.update({
|
||||
where: { id },
|
||||
data: others,
|
||||
include: { domain: true, department: true },
|
||||
});
|
||||
return updatedStaff;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update staff:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 批量删除员工记录(软删除)
|
||||
* @param data 包含要删除的员工ID数组的对象
|
||||
* @returns 删除操作结果,包括删除的记录数
|
||||
*/
|
||||
async batchDelete(data: z.infer<typeof StaffSchema.batchDelete>) {
|
||||
const { ids } = data;
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.',
|
||||
});
|
||||
}
|
||||
const deletedStaffs = await db.staff.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
if (!deletedStaffs.count) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No taxonomies were found with the provided IDs.',
|
||||
});
|
||||
}
|
||||
return { success: true, count: deletedStaffs.count };
|
||||
}
|
||||
/**
|
||||
* 分页查询员工
|
||||
* @param data 包含分页参数、域ID和部门ID的对象
|
||||
* @returns 员工列表及总记录数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof StaffSchema.paginate>) {
|
||||
const { page, pageSize, domainId, deptId, ids } = data;
|
||||
const childDepts = await this.departmentService.getAllChildDeptIds(deptId);
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.staff.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { order: 'asc' },
|
||||
where: {
|
||||
id: ids ? { in: ids } : undefined,
|
||||
deletedAt: null,
|
||||
domainId,
|
||||
deptId: deptId ? { in: [...childDepts, deptId] } : undefined,
|
||||
},
|
||||
include: { domain: true, department: true },
|
||||
}),
|
||||
db.staff.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
domainId,
|
||||
deptId: deptId ? { in: [...childDepts, deptId] } : undefined,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const processedItems = await Promise.all(
|
||||
items.map((item) => this.genStaffDto(item)),
|
||||
);
|
||||
return { items: processedItems, totalCount };
|
||||
}
|
||||
/**
|
||||
* 根据关键词或ID集合查找员工
|
||||
* @param data 包含关键词、域ID和ID集合的对象
|
||||
* @returns 匹配的员工记录列表
|
||||
*/
|
||||
async findMany(data: z.infer<typeof StaffSchema.findMany>) {
|
||||
const { keyword, domainId, ids } = data;
|
||||
|
||||
return await db.staff.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
domainId,
|
||||
OR: [
|
||||
{ username: { contains: keyword } },
|
||||
{
|
||||
id: { in: ids },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 生成员工的数据传输对象(DTO)
|
||||
* @param staff 员工记录
|
||||
* @returns 含角色ID列表的员工DTO
|
||||
*/
|
||||
private async genStaffDto(staff: Staff) {
|
||||
const roleMaps = await db.roleMap.findMany({
|
||||
where: {
|
||||
domainId: staff.domainId,
|
||||
objectId: staff.id,
|
||||
objectType: ObjectType.STAFF,
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
const roleIds = roleMaps.map((roleMap) => roleMap.role.id);
|
||||
return { ...staff, roleIds };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private emitDataChangedEvent(data: any, operation: CrudOperation) {
|
||||
EventBus.emit("dataChanged", {
|
||||
type: this.objectType,
|
||||
operation,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新员工DomainId
|
||||
* @param data 包含domainId对象
|
||||
* @returns 更新后的员工记录
|
||||
*/
|
||||
async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) {
|
||||
let { domainId } = data;
|
||||
if (staff.domainId !== domainId) {
|
||||
const result = await this.update({
|
||||
where: { id: staff.id },
|
||||
data: {
|
||||
domainId,
|
||||
deptId: null,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
return staff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// /**
|
||||
// * 根据关键词或ID集合查找员工
|
||||
// * @param data 包含关键词、域ID和ID集合的对象
|
||||
// * @returns 匹配的员工记录列表
|
||||
// */
|
||||
// async findMany(data: z.infer<typeof StaffMethodSchema.findMany>) {
|
||||
// const { keyword, domainId, ids, deptId, limit = 30 } = data;
|
||||
// const idResults = ids
|
||||
// ? await db.staff.findMany({
|
||||
// where: {
|
||||
// id: { in: ids },
|
||||
// deletedAt: null,
|
||||
// domainId,
|
||||
// deptId,
|
||||
// },
|
||||
// select: {
|
||||
// id: true,
|
||||
// showname: true,
|
||||
// username: true,
|
||||
// deptId: true,
|
||||
// domainId: true,
|
||||
// department: true,
|
||||
// domain: true,
|
||||
// },
|
||||
// })
|
||||
// : [];
|
||||
|
||||
// const mainResults = await db.staff.findMany({
|
||||
// where: {
|
||||
// deletedAt: null,
|
||||
// domainId,
|
||||
// deptId,
|
||||
// OR: (keyword || ids) && [
|
||||
// { showname: { contains: keyword } },
|
||||
// {
|
||||
// username: {
|
||||
// contains: keyword,
|
||||
// },
|
||||
// },
|
||||
// { phoneNumber: { contains: keyword } },
|
||||
// // {
|
||||
// // id: { in: ids },
|
||||
// // },
|
||||
// ],
|
||||
// },
|
||||
// select: {
|
||||
// id: true,
|
||||
// showname: true,
|
||||
// username: true,
|
||||
// deptId: true,
|
||||
// domainId: true,
|
||||
// department: true,
|
||||
// domain: true,
|
||||
// },
|
||||
// orderBy: { order: 'asc' },
|
||||
// take: limit !== -1 ? limit : undefined,
|
||||
// });
|
||||
// // Combine results, ensuring no duplicates
|
||||
// const combinedResults = [
|
||||
// ...mainResults,
|
||||
// ...idResults.filter(
|
||||
// (idResult) =>
|
||||
// !mainResults.some((mainResult) => mainResult.id === idResult.id),
|
||||
// ),
|
||||
// ];
|
||||
|
||||
// return combinedResults;
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nicestack/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,11 +2,11 @@ import { Module } from '@nestjs/common';
|
|||
import { TaxonomyRouter } from './taxonomy.router';
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { RedisModule } from '@server/redis/redis.module';
|
||||
import { TaxonomyController } from './taxonomy.controller';
|
||||
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
providers: [TaxonomyRouter, TaxonomyService, TrpcService],
|
||||
exports: [TaxonomyRouter, TaxonomyService]
|
||||
providers: [TaxonomyRouter, TaxonomyService, TrpcService],
|
||||
exports: [TaxonomyRouter, TaxonomyService],
|
||||
controllers: [TaxonomyController],
|
||||
})
|
||||
export class TaxonomyModule { }
|
||||
export class TaxonomyModule {}
|
||||
|
|
|
@ -1,42 +1,55 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { TaxonomyService } from './taxonomy.service';
|
||||
import { TaxonomySchema } from '@nicestack/common';
|
||||
import { TaxonomyMethodSchema } from '@nicestack/common';
|
||||
|
||||
@Injectable()
|
||||
export class TaxonomyRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly taxonomyService: TaxonomyService
|
||||
) { }
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly taxonomyService: TaxonomyService,
|
||||
) { }
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.procedure.input(TaxonomySchema.create).mutation(async ({ input }) => {
|
||||
return this.taxonomyService.create(input);
|
||||
}),
|
||||
|
||||
findById: this.trpc.procedure.input(TaxonomySchema.findById).query(async ({ input }) => {
|
||||
return this.taxonomyService.findById(input);
|
||||
}),
|
||||
|
||||
update: this.trpc.procedure.input(TaxonomySchema.update).mutation(async ({ input }) => {
|
||||
return this.taxonomyService.update(input);
|
||||
}),
|
||||
|
||||
delete: this.trpc.procedure.input(TaxonomySchema.delete).mutation(async ({ input }) => {
|
||||
return this.taxonomyService.delete(input);
|
||||
}),
|
||||
|
||||
batchDelete: this.trpc.procedure.input(TaxonomySchema.batchDelete).mutation(async ({ input }) => {
|
||||
return this.taxonomyService.batchDelete(input);
|
||||
}),
|
||||
|
||||
paginate: this.trpc.procedure.input(TaxonomySchema.paginate!).query(async ({ input }) => {
|
||||
return this.taxonomyService.paginate(input);
|
||||
}),
|
||||
|
||||
getAll: this.trpc.procedure.query(() => {
|
||||
return this.taxonomyService.getAll();
|
||||
})
|
||||
});
|
||||
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);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, TaxonomySchema, z } from '@nicestack/common';
|
||||
import { RedisService } from '@server/redis/redis.service';
|
||||
import { db, TaxonomyMethodSchema, Prisma } from '@nicestack/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(private readonly redis: RedisService) {}
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* 清除分页缓存,删除所有以'taxonomies:page:'开头的键
|
||||
*/
|
||||
private async invalidatePaginationCache() {
|
||||
const keys = await this.redis.keys('taxonomies:page:*');
|
||||
await Promise.all(keys.map((key) => this.redis.deleteKey(key)));
|
||||
deleteByPattern('taxonomies:page:*')
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -20,7 +21,7 @@ export class TaxonomyService {
|
|||
* @param input 分类创建信息
|
||||
* @returns 新创建的分类记录
|
||||
*/
|
||||
async create(input: z.infer<typeof TaxonomySchema.create>) {
|
||||
async create(input: z.infer<typeof TaxonomyMethodSchema.create>) {
|
||||
// 获取当前分类数量,设置新分类的order值为count + 1
|
||||
const count = await db.taxonomy.count();
|
||||
const taxonomy = await db.taxonomy.create({
|
||||
|
@ -28,7 +29,7 @@ export class TaxonomyService {
|
|||
});
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await this.redis.deleteKey(`taxonomy:${taxonomy.id}`);
|
||||
await redis.del(`taxonomy:${taxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return taxonomy;
|
||||
}
|
||||
|
@ -38,16 +39,29 @@ export class TaxonomyService {
|
|||
* @param input 包含分类name的对象
|
||||
* @returns 查找到的分类记录
|
||||
*/
|
||||
async findByName(input: z.infer<typeof TaxonomySchema.findByName>) {
|
||||
async findByName(input: z.infer<typeof TaxonomyMethodSchema.findByName>) {
|
||||
const { name } = input;
|
||||
const cacheKey = `taxonomy:${name}`;
|
||||
let cachedTaxonomy = await this.redis.getValue(cacheKey);
|
||||
let cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { name: name } });
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { name } });
|
||||
if (taxonomy) {
|
||||
await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60);
|
||||
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}`;
|
||||
let 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;
|
||||
}
|
||||
|
@ -56,15 +70,15 @@ export class TaxonomyService {
|
|||
* @param input 包含分类ID的对象
|
||||
* @returns 查找到的分类记录
|
||||
*/
|
||||
async findById(input: z.infer<typeof TaxonomySchema.findById>) {
|
||||
async findById(input: z.infer<typeof TaxonomyMethodSchema.findById>) {
|
||||
const cacheKey = `taxonomy:${input.id}`;
|
||||
let cachedTaxonomy = await this.redis.getValue(cacheKey);
|
||||
let cachedTaxonomy = await redis.get(cacheKey);
|
||||
if (cachedTaxonomy) {
|
||||
return JSON.parse(cachedTaxonomy);
|
||||
}
|
||||
const taxonomy = await db.taxonomy.findUnique({ where: { id: input.id } });
|
||||
if (taxonomy) {
|
||||
await this.redis.setWithExpiry(cacheKey, JSON.stringify(taxonomy), 60);
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(taxonomy));
|
||||
}
|
||||
return taxonomy;
|
||||
}
|
||||
|
@ -79,7 +93,7 @@ export class TaxonomyService {
|
|||
const updatedTaxonomy = await db.taxonomy.update({ where: { id }, data });
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await this.redis.deleteKey(`taxonomy:${updatedTaxonomy.id}`);
|
||||
await redis.del(`taxonomy:${updatedTaxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return updatedTaxonomy;
|
||||
}
|
||||
|
@ -96,7 +110,7 @@ export class TaxonomyService {
|
|||
});
|
||||
|
||||
// 删除该分类的缓存及分页缓存
|
||||
await this.redis.deleteKey(`taxonomy:${deletedTaxonomy.id}`);
|
||||
await redis.del(`taxonomy:${deletedTaxonomy.id}`);
|
||||
await this.invalidatePaginationCache();
|
||||
return deletedTaxonomy;
|
||||
}
|
||||
|
@ -106,7 +120,7 @@ export class TaxonomyService {
|
|||
* @param input 包含要删除的分类ID数组的对象
|
||||
* @returns 删除操作结果,包括删除的记录数
|
||||
*/
|
||||
async batchDelete(input: any) {
|
||||
async deleteMany(input: any) {
|
||||
const { ids } = input;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
|
@ -120,16 +134,9 @@ export class TaxonomyService {
|
|||
},
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
if (!deletedTaxonomies.count) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No taxonomies were found with the provided IDs.',
|
||||
});
|
||||
}
|
||||
|
||||
// 删除每个分类的缓存及分页缓存
|
||||
await Promise.all(
|
||||
ids.map(async (id: string) => this.redis.deleteKey(`taxonomy:${id}`)),
|
||||
ids.map(async (id: string) => redis.del(`taxonomy:${id}`)),
|
||||
);
|
||||
await this.invalidatePaginationCache();
|
||||
return { success: true, count: deletedTaxonomies.count };
|
||||
|
@ -142,7 +149,7 @@ export class TaxonomyService {
|
|||
*/
|
||||
async paginate(input: any) {
|
||||
const cacheKey = `taxonomies:page:${input.page}:size:${input.pageSize}`;
|
||||
let cachedData = await this.redis.getValue(cacheKey);
|
||||
let cachedData = await redis.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return JSON.parse(cachedData);
|
||||
}
|
||||
|
@ -159,7 +166,7 @@ export class TaxonomyService {
|
|||
const result = { items, totalCount };
|
||||
|
||||
// 缓存结果并设置过期时间
|
||||
await this.redis.setWithExpiry(cacheKey, JSON.stringify(result), 60);
|
||||
await redis.setex(cacheKey, 60, JSON.stringify(result));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
@ -167,10 +174,30 @@ export class TaxonomyService {
|
|||
* 获取所有未删除的分类记录
|
||||
* @returns 分类记录列表
|
||||
*/
|
||||
async getAll() {
|
||||
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: { deletedAt: null },
|
||||
where: filter,
|
||||
orderBy: { order: 'asc' },
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
slug: true,
|
||||
objectType: true,
|
||||
order: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { TermService } from './term.service';
|
||||
import { AuthGuard } from '@server/auth/auth.guard';
|
||||
import { db } from '@nicestack/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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,13 +3,14 @@ import { TermService } from './term.service';
|
|||
import { TermRouter } from './term.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '../department/department.module';
|
||||
import { RbacModule } from '@server/rbac/rbac.module';
|
||||
import { RelationService } from '@server/relation/relation.service';
|
||||
|
||||
import { TermController } from './term.controller';
|
||||
import { RoleMapModule } from '../rbac/rbac.module';
|
||||
import { TermRowService } from './term.row.service';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentModule, RbacModule],
|
||||
providers: [TermService, TermRouter, TrpcService, RelationService],
|
||||
exports: [TermService, TermRouter]
|
||||
imports: [DepartmentModule, RoleMapModule],
|
||||
providers: [TermService, TermRouter, TrpcService, TermRowService],
|
||||
exports: [TermService, TermRouter],
|
||||
controllers: [TermController],
|
||||
})
|
||||
export class TermModule { }
|
||||
|
|
|
@ -1,55 +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 { z, TermSchema } from '@nicestack/common';
|
||||
|
||||
import { Prisma, TermMethodSchema, UpdateOrderSchema } from '@nicestack/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,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(TermSchema.create)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { staff } = ctx
|
||||
return this.termService.create(staff, input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(TermSchema.update)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.termService.update(input);
|
||||
}),
|
||||
delete: this.trpc.protectProcedure
|
||||
.input(TermSchema.delete)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.termService.delete(input);
|
||||
}),
|
||||
findById: this.trpc.procedure.input(z.object({
|
||||
id: z.string()
|
||||
})).query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx
|
||||
return this.termService.findUnique(staff, input.id)
|
||||
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()),
|
||||
}),
|
||||
batchDelete: this.trpc.protectProcedure.input(z.object({
|
||||
ids: z.array(z.string())
|
||||
})).mutation(async ({ input }) => {
|
||||
const { ids } = input
|
||||
return this.termService.batchDelete(ids)
|
||||
}),
|
||||
getChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx
|
||||
return this.termService.getChildren(staff, input)
|
||||
}),
|
||||
getAllChildren: this.trpc.procedure.input(TermSchema.getChildren).query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx
|
||||
return this.termService.getAllChildren(staff, input)
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(TermSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.termService.findMany(input);
|
||||
}),
|
||||
});
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return this.termService.upsertTags(staff, input.tags);
|
||||
}),
|
||||
getChildSimpleTree: this.trpc.procedure
|
||||
.input(TermMethodSchema.getSimpleTree)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.termService.getChildSimpleTree(staff, input);
|
||||
}),
|
||||
getParentSimpleTree: this.trpc.procedure
|
||||
.input(TermMethodSchema.getSimpleTree)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.termService.getParentSimpleTree(staff, input);
|
||||
}),
|
||||
getTreeData: this.trpc.protectProcedure
|
||||
.input(TermMethodSchema.getTreeData)
|
||||
.query(async ({ input }) => {
|
||||
return await this.termService.getTreeData(input);
|
||||
}),
|
||||
getRows: this.trpc.protectProcedure
|
||||
.input(TermMethodSchema.getRows)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await this.termRowService.getRows(input, ctx.staff);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ObjectType,
|
||||
RowModelRequest,
|
||||
TermMethodSchema,
|
||||
UserProfile,
|
||||
} from '@nicestack/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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,367 +1,425 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { z, TermSchema, db, Staff, Term, RelationType, ObjectType, Prisma, TermDto } from '@nicestack/common';
|
||||
import { RolePermsService } from '@server/rbac/roleperms.service';
|
||||
import { RelationService } from '@server/relation/relation.service';
|
||||
import {
|
||||
TermMethodSchema,
|
||||
db,
|
||||
Staff,
|
||||
Term,
|
||||
Prisma,
|
||||
TermDto,
|
||||
TreeDataNode,
|
||||
UserProfile,
|
||||
getUniqueItems,
|
||||
RolePerms,
|
||||
TaxonomySlug,
|
||||
ObjectType,
|
||||
TermAncestry,
|
||||
} from '@nicestack/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';
|
||||
|
||||
/**
|
||||
* Service for managing terms and their ancestries.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TermService {
|
||||
constructor(private readonly permissionService: RolePermsService, private readonly relations: RelationService) { }
|
||||
export class TermService extends BaseTreeService<Prisma.TermDelegate> {
|
||||
constructor() {
|
||||
super(db, ObjectType.TERM, 'termAncestry', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成TermDto对象,包含权限和关系信息
|
||||
* @param staff 当前操作的工作人员
|
||||
* @param term 当前处理的术语对象,包含其子节点
|
||||
* @returns 完整的TermDto对象
|
||||
*/
|
||||
async genTermDto(staff: Staff, term: Term & { children: Term[] }): Promise<TermDto> {
|
||||
const { children, ...others } = term as any;
|
||||
const permissions = this.permissionService.getTermPerms(staff, term);
|
||||
const relationTypes = [
|
||||
{ type: RelationType.WATCH, object: ObjectType.DEPARTMENT, key: 'watchDeptIds', limit: undefined },
|
||||
{ type: RelationType.WATCH, object: ObjectType.STAFF, key: 'watchStaffIds', limit: undefined }
|
||||
] as const;
|
||||
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;
|
||||
}
|
||||
|
||||
type RelationResult = {
|
||||
[key in typeof relationTypes[number]['key']]: string[];
|
||||
};
|
||||
/**
|
||||
* 删除现有单位并清理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;
|
||||
}
|
||||
|
||||
const promises = relationTypes.map(async ({ type, object, key, limit }) => ({
|
||||
[key]: await this.relations.getEROBids(ObjectType.TERM, type, object, term.id, limit)
|
||||
}));
|
||||
// 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 = await Promise.all(promises);
|
||||
const mergedResults = Object.assign({}, ...(results as Partial<RelationResult>[]));
|
||||
// const results = getUniqueItems(
|
||||
// [...initialTerms, ...terms].filter(Boolean),
|
||||
// 'id',
|
||||
// );
|
||||
// return results;
|
||||
// }
|
||||
// /**
|
||||
|
||||
return { ...others, ...mergedResults, permissions, hasChildren: term.children.length > 0 };
|
||||
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' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定父节点下新的排序值。
|
||||
*
|
||||
* @param parentId 父节点ID,如果是根节点则为null
|
||||
* @returns 下一个排序值
|
||||
*/
|
||||
private async getNextOrder(parentId?: string) {
|
||||
let newOrder = 0;
|
||||
|
||||
if (parentId) {
|
||||
const siblingTerms = await db.term.findMany({
|
||||
where: { parentId },
|
||||
orderBy: { order: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (siblingTerms.length > 0) {
|
||||
newOrder = siblingTerms[0].order + 1;
|
||||
}
|
||||
} else {
|
||||
const rootTerms = await db.term.findMany({
|
||||
where: { parentId: null },
|
||||
orderBy: { order: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (rootTerms.length > 0) {
|
||||
newOrder = rootTerms[0].order + 1;
|
||||
}
|
||||
// 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[];
|
||||
}
|
||||
|
||||
return newOrder;
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* 创建关系数据用于批量插入。
|
||||
*
|
||||
* @param termId 术语ID
|
||||
* @param watchDeptIds 监控部门ID数组
|
||||
* @param watchStaffIds 监控员工ID数组
|
||||
* @returns 关系数据数组
|
||||
*/
|
||||
private createRelations(
|
||||
termId: string,
|
||||
watchDeptIds: string[],
|
||||
watchStaffIds: string[]
|
||||
) {
|
||||
const relationsData = [
|
||||
...watchDeptIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.DEPARTMENT, RelationType.WATCH)),
|
||||
...watchStaffIds.map(bId => this.relations.buildRelation(termId, bId, ObjectType.TERM, ObjectType.STAFF, RelationType.WATCH)),
|
||||
];
|
||||
return relationsData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的术语并根据需要创建祖先关系。
|
||||
*
|
||||
* @param data 创建新术语的数据
|
||||
* @returns 新创建的术语
|
||||
*/
|
||||
async create(staff: Staff, data: z.infer<typeof TermSchema.create>) {
|
||||
const { parentId, watchDeptIds = [], watchStaffIds = [], ...others } = data;
|
||||
|
||||
return await db.$transaction(async (trx) => {
|
||||
const order = await this.getNextOrder(parentId);
|
||||
|
||||
const newTerm = await trx.term.create({
|
||||
data: {
|
||||
...others,
|
||||
parentId,
|
||||
order,
|
||||
createdBy: staff.id
|
||||
},
|
||||
});
|
||||
|
||||
if (parentId) {
|
||||
const parentTerm = await trx.term.findUnique({
|
||||
where: { id: parentId },
|
||||
include: { ancestors: true },
|
||||
});
|
||||
|
||||
const ancestries = parentTerm.ancestors.map((ancestor) => ({
|
||||
ancestorId: ancestor.ancestorId,
|
||||
descendantId: newTerm.id,
|
||||
relDepth: ancestor.relDepth + 1,
|
||||
}));
|
||||
|
||||
ancestries.push({
|
||||
ancestorId: parentTerm.id,
|
||||
descendantId: newTerm.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await trx.termAncestry.createMany({ data: ancestries });
|
||||
}
|
||||
|
||||
const relations = this.createRelations(newTerm.id, watchDeptIds, watchStaffIds);
|
||||
await trx.relation.createMany({ data: relations });
|
||||
return newTerm;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新现有术语的数据,并在parentId改变时管理术语祖先关系。
|
||||
*
|
||||
* @param data 更新术语的数据
|
||||
* @returns 更新后的术语
|
||||
*/
|
||||
async update(data: z.infer<typeof TermSchema.update>) {
|
||||
return await db.$transaction(async (prisma) => {
|
||||
const currentTerm = await prisma.term.findUnique({
|
||||
where: { id: data.id },
|
||||
});
|
||||
if (!currentTerm) throw new Error('Term not found');
|
||||
console.log(data)
|
||||
const updatedTerm = await prisma.term.update({
|
||||
where: { id: data.id },
|
||||
data,
|
||||
});
|
||||
|
||||
if (data.parentId !== currentTerm.parentId) {
|
||||
await prisma.termAncestry.deleteMany({
|
||||
where: { descendantId: data.id },
|
||||
});
|
||||
|
||||
if (data.parentId) {
|
||||
const parentAncestries = await prisma.termAncestry.findMany({
|
||||
where: { descendantId: data.parentId },
|
||||
});
|
||||
|
||||
const newAncestries = parentAncestries.map(ancestry => ({
|
||||
ancestorId: ancestry.ancestorId,
|
||||
descendantId: data.id,
|
||||
relDepth: ancestry.relDepth + 1,
|
||||
}));
|
||||
|
||||
newAncestries.push({
|
||||
ancestorId: data.parentId,
|
||||
descendantId: data.id,
|
||||
relDepth: 1,
|
||||
});
|
||||
|
||||
await prisma.termAncestry.createMany({
|
||||
data: newAncestries,
|
||||
});
|
||||
|
||||
const order = await this.getNextOrder(data.parentId);
|
||||
await prisma.term.update({
|
||||
where: { id: data.id },
|
||||
data: { order },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.watchDeptIds || data.watchStaffIds) {
|
||||
await prisma.relation.deleteMany({ where: { aId: data.id, relationType: { in: [RelationType.WATCH] } } });
|
||||
|
||||
const relations = this.createRelations(
|
||||
data.id,
|
||||
data.watchDeptIds ?? [],
|
||||
data.watchStaffIds ?? []
|
||||
);
|
||||
|
||||
await prisma.relation.createMany({ data: relations });
|
||||
}
|
||||
|
||||
return updatedTerm;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID删除现有术语。
|
||||
*
|
||||
* @param data 删除术语的数据
|
||||
* @returns 被删除的术语
|
||||
*/
|
||||
async delete(data: z.infer<typeof TermSchema.delete>) {
|
||||
const { id } = data;
|
||||
|
||||
await db.termAncestry.deleteMany({
|
||||
where: { OR: [{ ancestorId: id }, { descendantId: id }] },
|
||||
});
|
||||
|
||||
const deletedTerm = await db.term.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return deletedTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除术语。
|
||||
*
|
||||
* @param ids 要删除的术语ID数组
|
||||
* @returns 已删除的术语列表
|
||||
*/
|
||||
async batchDelete(ids: string[]) {
|
||||
await db.termAncestry.deleteMany({
|
||||
where: { OR: [{ ancestorId: { in: ids } }, { descendantId: { in: ids } }] },
|
||||
});
|
||||
|
||||
const deletedTerms = await db.term.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
return deletedTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找唯一术语并生成TermDto对象。
|
||||
*
|
||||
* @param staff 当前操作的工作人员
|
||||
* @param id 术语ID
|
||||
* @returns 包含详细信息的术语对象
|
||||
*/
|
||||
async findUnique(staff: Staff, id: string) {
|
||||
const term = await db.term.findUnique({
|
||||
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: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
domain: true,
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
return await this.genTermDto(staff, term);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定条件下的术语子节点。
|
||||
*
|
||||
* @param staff 当前操作的工作人员
|
||||
* @param data 查询条件
|
||||
* @returns 子节点术语列表
|
||||
*/
|
||||
async getChildren(staff: Staff, data: z.infer<typeof TermSchema.getChildren>) {
|
||||
const { parentId, domainId, taxonomyId, cursor, limit = 10 } = data;
|
||||
const extraCondition = await this.permissionService.getTermExtraConditions(staff);
|
||||
let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null }
|
||||
const whereCondition: Prisma.TermWhereInput = {
|
||||
AND: [extraCondition, queryCondition],
|
||||
};
|
||||
console.log(JSON.stringify(whereCondition))
|
||||
const terms = await db.term.findMany({
|
||||
where: whereCondition,
|
||||
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.genTermDto(staff, item)));
|
||||
return {
|
||||
items: termDtos,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 获取指定条件下的所有术语子节点。
|
||||
*
|
||||
* @param staff 当前操作的工作人员
|
||||
* @param data 查询条件
|
||||
* @returns 子节点术语列表
|
||||
*/
|
||||
async getAllChildren(staff: Staff, data: z.infer<typeof TermSchema.getChildren>) {
|
||||
const { parentId, domainId, taxonomyId } = data;
|
||||
const extraCondition = await this.permissionService.getTermExtraConditions(staff);
|
||||
let queryCondition: Prisma.TermWhereInput = { taxonomyId, parentId: parentId === undefined ? null : parentId, domainId, deletedAt: null }
|
||||
|
||||
const whereCondition: Prisma.TermWhereInput = {
|
||||
AND: [extraCondition, queryCondition],
|
||||
};
|
||||
console.log(JSON.stringify(whereCondition))
|
||||
const terms = await db.term.findMany({
|
||||
where: whereCondition,
|
||||
include: {
|
||||
children: {
|
||||
where: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return await Promise.all(terms.map((item) => this.genTermDto(staff, item)));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关键词或ID集合查找术语
|
||||
* @param data 包含关键词、域ID和ID集合的对象
|
||||
* @returns 匹配的术语记录列表
|
||||
*/
|
||||
async findMany(data: z.infer<typeof TermSchema.findMany>) {
|
||||
const { keyword, taxonomyId, ids } = data;
|
||||
|
||||
return await db.term.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
taxonomyId,
|
||||
...(termIds && {
|
||||
OR: [
|
||||
{ name: { contains: keyword } },
|
||||
{
|
||||
id: { in: ids }
|
||||
}
|
||||
]
|
||||
...(validTermIds.length
|
||||
? [{ id: { in: validTermIds } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
taxonomyId: taxonomyId,
|
||||
// 动态权限控制条件
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
take: 20
|
||||
});
|
||||
}
|
||||
include: { children: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const children = childrenData
|
||||
.map(({ descendant }) => descendant)
|
||||
.filter(Boolean)
|
||||
.map(formatToTermTreeData);
|
||||
const selfItems = selfData.map(formatToTermTreeData);
|
||||
return getUniqueItems([...children, ...selfItems], 'id');
|
||||
}
|
||||
|
||||
async getParentSimpleTree(
|
||||
staff: UserProfile,
|
||||
data: z.infer<typeof TermMethodSchema.getSimpleTree>,
|
||||
) {
|
||||
const { domainId = null, permissions } = staff;
|
||||
const hasAnyPerms =
|
||||
permissions.includes(RolePerms.READ_ANY_TERM) ||
|
||||
permissions.includes(RolePerms.MANAGE_ANY_TERM);
|
||||
// 解构输入参数
|
||||
const { termIds, taxonomyId } = data;
|
||||
|
||||
// 并行查询父级部门ancestry和自身部门数据
|
||||
// 使用Promise.all提高查询效率,减少等待时间
|
||||
const [parentData, selfData] = await Promise.all([
|
||||
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
|
||||
db.termAncestry.findMany({
|
||||
where: {
|
||||
descendantId: { in: termIds }, // 查询条件:descendant在给定的部门ID列表中
|
||||
ancestor: {
|
||||
taxonomyId: taxonomyId,
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
ancestor: {
|
||||
include: {
|
||||
children: true, // 包含子节点信息
|
||||
parent: true, // 包含父节点信息
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
|
||||
}),
|
||||
|
||||
// 查询自身部门数据
|
||||
db.term.findMany({
|
||||
where: {
|
||||
id: { in: termIds },
|
||||
taxonomyId: taxonomyId,
|
||||
...(hasAnyPerms
|
||||
? {} // 当有全局权限时,不添加任何额外条件
|
||||
: {
|
||||
// 当无全局权限时,添加域ID过滤
|
||||
OR: [
|
||||
{ domainId: null }, // 通用记录
|
||||
{ domainId: domainId }, // 特定域记录
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: { children: true }, // 包含子节点信息
|
||||
orderBy: { order: 'asc' }, // 按顺序升序排序
|
||||
}),
|
||||
]);
|
||||
|
||||
// 处理父级节点:过滤并映射为简单树结构
|
||||
const parents = parentData
|
||||
.map(({ ancestor }) => ancestor) // 提取祖先节点
|
||||
.filter((ancestor) => ancestor) // 过滤有效且超出根节点层级的节点
|
||||
.map(mapToTermSimpleTree); // 映射为简单树结构
|
||||
|
||||
// 处理自身节点:映射为简单树结构
|
||||
const selfItems = selfData.map(mapToTermSimpleTree);
|
||||
|
||||
// 合并并去重父级和自身节点,返回唯一项
|
||||
return getUniqueItems([...parents, ...selfItems], 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { TreeDataNode } from '@nicestack/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),
|
||||
};
|
||||
}
|
24
apps/server/src/transform/transform.module.ts → apps/server/src/models/transform/transform.module.ts
Normal file → Executable file
24
apps/server/src/transform/transform.module.ts → apps/server/src/models/transform/transform.module.ts
Normal file → Executable file
|
@ -1,15 +1,21 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TransformService } from './transform.service';
|
||||
import { TransformRouter } from './transform.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { DepartmentModule } from '@server/models/department/department.module';
|
||||
import { StaffModule } from '@server/models/staff/staff.module';
|
||||
import { TaxonomyModule } from '@server/models/taxonomy/taxonomy.module';
|
||||
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, TaxonomyModule, TermModule],
|
||||
imports: [
|
||||
DepartmentModule,
|
||||
StaffModule,
|
||||
TermModule,
|
||||
TaxonomyModule,
|
||||
],
|
||||
providers: [TransformService, TransformRouter, TrpcService],
|
||||
exports: [TransformRouter]
|
||||
exports: [TransformRouter, TransformService],
|
||||
// controllers:[TransformController]
|
||||
})
|
||||
export class TransformModule { }
|
||||
export class TransformModule {}
|
17
apps/server/src/transform/transform.router.ts → apps/server/src/models/transform/transform.router.ts
Normal file → Executable file
17
apps/server/src/transform/transform.router.ts → apps/server/src/models/transform/transform.router.ts
Normal file → Executable file
|
@ -1,32 +1,35 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TransformService } from './transform.service';
|
||||
import { TransformSchema } from '@nicestack/common';
|
||||
import { TrpcService } from '../trpc/trpc.service';
|
||||
import { TransformMethodSchema} from '@nicestack/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(TransformSchema.importTerms) // expect input according to the schema
|
||||
.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(TransformSchema.importDepts) // expect input according to the schema
|
||||
.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(TransformSchema.importStaffs) // expect input according to the schema
|
||||
.input(TransformMethodSchema.importStaffs) // expect input according to the schema
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return this.transformService.importStaffs(input);
|
||||
}),
|
||||
|
||||
|
||||
});
|
||||
}
|
|
@ -0,0 +1,551 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
TroubleType,
|
||||
TroubleState,
|
||||
TransformMethodSchema,
|
||||
db,
|
||||
Prisma,
|
||||
Staff,
|
||||
|
||||
} from '@nicestack/common';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import * as argon2 from 'argon2';
|
||||
import { TaxonomyService } from '@server/models/taxonomy/taxonomy.service';
|
||||
import { uploadFile } from '@server/utils/tool';
|
||||
import { DepartmentService } from '../department/department.service';
|
||||
import { StaffService } from '../staff/staff.service';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||
|
||||
class TreeNode {
|
||||
value: string;
|
||||
children: TreeNode[];
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value;
|
||||
this.children = [];
|
||||
}
|
||||
|
||||
addChild(childValue: string): TreeNode {
|
||||
let newChild = undefined;
|
||||
if (this.children.findIndex((child) => child.value === childValue) === -1) {
|
||||
newChild = new TreeNode(childValue);
|
||||
this.children.push(newChild);
|
||||
}
|
||||
return this.children.find((child) => child.value === childValue);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService,
|
||||
private readonly staffService: StaffService,
|
||||
private readonly taxonomyService: TaxonomyService,
|
||||
) {}
|
||||
private readonly logger = new Logger(TransformService.name);
|
||||
|
||||
excelDateToISO(excelDate: number) {
|
||||
// 设置 Excel 序列号的起点
|
||||
const startDate = dayjs('1899-12-31');
|
||||
// 加上 Excel 中的天数(注意必须减去2,因为 Excel 错误地把1900年当作闰年)
|
||||
const date = startDate.add(excelDate, 'day');
|
||||
// 转换为 ISO 字符串
|
||||
return date.toDate();
|
||||
}
|
||||
async getDepts(domainId: string, cellStr: string) {
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const depts: string[] = [];
|
||||
if (pattern.test(cellStr)) {
|
||||
const deptNames = cellStr.split(pattern);
|
||||
for (const name of deptNames) {
|
||||
const dept = await this.departmentService.findInDomain(domainId, name);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
} else {
|
||||
const dept = await this.departmentService.findInDomain(domainId, cellStr);
|
||||
if (dept) depts.push(dept.id);
|
||||
}
|
||||
|
||||
if (depts.length === 0) {
|
||||
this.logger.error(`未找到单位:${cellStr}`);
|
||||
}
|
||||
return depts;
|
||||
}
|
||||
async getStaffs(deptIds: string[], cellStr: string) {
|
||||
const staffs: string[] = [];
|
||||
const pattern = /[\s、,,;.。;\n]+/;
|
||||
const allStaffsArrays = await Promise.all(
|
||||
deptIds.map((deptId) => this.staffService.findByDept({ deptId })),
|
||||
);
|
||||
const combinedStaffs = allStaffsArrays.reduce(
|
||||
(acc, curr) => acc.concat(curr),
|
||||
[],
|
||||
);
|
||||
if (pattern.test(cellStr)) {
|
||||
const staffNames = cellStr.split(pattern);
|
||||
|
||||
for (const name of staffNames) {
|
||||
if (
|
||||
combinedStaffs.map((staff, index) => staff?.showname).includes(name)
|
||||
) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff?.showname === name,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
}
|
||||
} else {
|
||||
// const staff = await this.lanxin.getStaffsByDepartment(deptIds);
|
||||
// if (staff) staffs.push(staff.staffId);
|
||||
if (
|
||||
combinedStaffs.map((staff, index) => staff?.showname).includes(cellStr)
|
||||
) {
|
||||
const staffWithName = combinedStaffs.find(
|
||||
(staff) => staff?.showname === cellStr,
|
||||
);
|
||||
if (staffWithName) {
|
||||
// 将该员工的 ID 添加到 staffIds 数组中
|
||||
staffs.push(staffWithName.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (staffs.length === 0) {
|
||||
this.logger.error(`未找到人员:${cellStr}`);
|
||||
}
|
||||
return staffs;
|
||||
}
|
||||
|
||||
buildTree(data: string[][]): TreeNode {
|
||||
const root = new TreeNode('root');
|
||||
try {
|
||||
for (const path of data) {
|
||||
let currentNode = root;
|
||||
for (const value of path) {
|
||||
currentNode = currentNode.addChild(value);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
async generateTreeFromFile(file: Buffer): Promise<{ tree: TreeNode }> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file);
|
||||
const worksheet = workbook.getWorksheet(1);
|
||||
|
||||
const data: string[][] = [];
|
||||
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) {
|
||||
// Skip header row if any
|
||||
try {
|
||||
const rowData: string[] = (row.values as string[])
|
||||
.slice(2)
|
||||
.map((cell) => (cell || '').toString());
|
||||
data.push(rowData.map((value) => value.trim()));
|
||||
} catch (error) {
|
||||
throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Fill forward values
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
for (let j = 0; j < data[i].length; j++) {
|
||||
if (!data[i][j]) data[i][j] = data[i - 1][j];
|
||||
}
|
||||
}
|
||||
return { tree: this.buildTree(data) };
|
||||
}
|
||||
printTree(node: TreeNode, level: number = 0): void {
|
||||
const indent = ' '.repeat(level);
|
||||
|
||||
for (const child of node.children) {
|
||||
this.printTree(child, level + 1);
|
||||
}
|
||||
}
|
||||
swapKeyValue<T extends Record<string, string>>(
|
||||
input: T,
|
||||
): { [K in T[keyof T]]: Extract<keyof T, string> } {
|
||||
const result: Partial<{ [K in T[keyof T]]: Extract<keyof T, string> }> = {};
|
||||
for (const key in input) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, key)) {
|
||||
const value = input[key];
|
||||
result[value] = key;
|
||||
}
|
||||
}
|
||||
return result as { [K in T[keyof T]]: Extract<keyof T, string> };
|
||||
}
|
||||
isEmptyRow(row: any) {
|
||||
return row.every((cell: any) => {
|
||||
return !cell || cell.toString().trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
async importStaffs(data: z.infer<typeof TransformMethodSchema.importStaffs>) {
|
||||
const { base64, domainId } = data;
|
||||
this.logger.log('开始');
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const importsStaffMethodSchema = z.object({
|
||||
name: z.string(),
|
||||
phoneNumber: z.string().regex(/^\d+$/), // Assuming phone numbers should be numeric
|
||||
deptName: z.string(),
|
||||
});
|
||||
const worksheet = workbook.getWorksheet(1); // Assuming the data is in the first sheet
|
||||
const staffs: { name: string; phoneNumber: string; deptName: string }[] =
|
||||
[];
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber > 1) {
|
||||
// Assuming the first row is headers
|
||||
const name = row.getCell(1).value as string;
|
||||
const phoneNumber = row.getCell(2).value.toString() as string;
|
||||
const deptName = row.getCell(3).value as string;
|
||||
try {
|
||||
importsStaffMethodSchema.parse({ name, phoneNumber, deptName });
|
||||
staffs.push({ name, phoneNumber, deptName });
|
||||
} catch (error) {
|
||||
throw new Error(`发生报错!位于第${rowNumber}行,错误: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 获取所有唯一的部门名称
|
||||
const uniqueDeptNames = [...new Set(staffs.map((staff) => staff.deptName))];
|
||||
// 获取所有部门名称对应的部门ID
|
||||
const deptIdsMap = await this.departmentService.getDeptIdsByNames(
|
||||
uniqueDeptNames,
|
||||
domainId,
|
||||
);
|
||||
const count = await db.staff.count();
|
||||
const hashedPassword = await argon2.hash('123456');
|
||||
|
||||
// 为员工数据添加部门ID
|
||||
const staffsToCreate = staffs.map((staff, index) => ({
|
||||
showname: staff.name,
|
||||
username: staff.phoneNumber,
|
||||
phoneNumber: staff.phoneNumber,
|
||||
password: hashedPassword,
|
||||
deptId: deptIdsMap[staff.deptName],
|
||||
domainId,
|
||||
order: index + count,
|
||||
}));
|
||||
// 批量创建员工数据
|
||||
const createdStaffs = await db.staff.createMany({
|
||||
data: staffsToCreate,
|
||||
});
|
||||
await deleteByPattern('row-*');
|
||||
return createdStaffs;
|
||||
}
|
||||
async importTerms(
|
||||
staff: Staff,
|
||||
data: z.infer<typeof TransformMethodSchema.importTerms>,
|
||||
) {
|
||||
const { base64, domainId, taxonomyId, parentId } = data;
|
||||
this.logger.log('开始');
|
||||
await db.$transaction(async (tx) => {
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
const { tree: root } = await this.generateTreeFromFile(buffer);
|
||||
|
||||
this.printTree(root);
|
||||
|
||||
const termsData: Prisma.TermCreateManyInput[] = [];
|
||||
const termAncestriesData: Prisma.TermAncestryCreateManyInput[] = [];
|
||||
if (!taxonomyId) {
|
||||
throw new Error('未指定分类!');
|
||||
}
|
||||
this.logger.log('存在taxonomyId');
|
||||
const taxonomy = await tx.taxonomy.findUnique({
|
||||
where: { id: taxonomyId },
|
||||
});
|
||||
if (!taxonomy) {
|
||||
throw new Error('未找到对应分类');
|
||||
}
|
||||
const count = await tx.term.count({ where: { taxonomyId: taxonomyId } });
|
||||
let termIndex = 0;
|
||||
this.logger.log(count);
|
||||
|
||||
const gatherTermsData = async (nodes: TreeNode[], depth = 0) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const termData = {
|
||||
name: node.value,
|
||||
taxonomyId: taxonomyId,
|
||||
domainId: domainId,
|
||||
createdBy: staff.id,
|
||||
order: count + termIndex + 1,
|
||||
};
|
||||
termsData.push(termData);
|
||||
termIndex++;
|
||||
// Debug: Log term data preparation
|
||||
|
||||
await gatherTermsData(node.children, depth + 1);
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
await gatherTermsData(root.children);
|
||||
let createdTerms: { id: string; name: string }[] = [];
|
||||
try {
|
||||
createdTerms = await tx.term.createManyAndReturn({
|
||||
data: termsData,
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
// Debug: Log created terms
|
||||
} catch (error) {
|
||||
console.error('创建Terms报错:', error);
|
||||
throw new Error('创建失败');
|
||||
}
|
||||
const termsUpdate = [];
|
||||
|
||||
const gatherAncestryData = (
|
||||
nodes: TreeNode[],
|
||||
ancestors: string[] = parentId ? [null, parentId] : [null],
|
||||
depth = 0,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
const dept = createdTerms.find((dept) => dept.name === node.value);
|
||||
if (dept) {
|
||||
termsUpdate.push({
|
||||
where: { id: dept.id },
|
||||
data: { parentId: ancestors[ancestors.length - 1] },
|
||||
});
|
||||
for (let i = 0; i < ancestors.length; i++) {
|
||||
const ancestryData = {
|
||||
ancestorId: ancestors[i],
|
||||
descendantId: dept.id,
|
||||
relDepth: depth - i + 1,
|
||||
};
|
||||
termAncestriesData.push(ancestryData);
|
||||
}
|
||||
const newAncestors = [...ancestors, dept.id];
|
||||
gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
}
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
// console.log(`depth:${depth}`);
|
||||
// for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
// const term = createdTerms.find((term) => term.name === node.value);
|
||||
// if (term) {
|
||||
// termsUpdate.push({
|
||||
// where: { id: term.id },
|
||||
// data: { parentId: ancestors[ancestors.length - 1] },
|
||||
// });
|
||||
// for (let i = 0; i < ancestors.length; i++) {
|
||||
// const ancestryData = {
|
||||
// ancestorId: ancestors[i],
|
||||
// descendantId: term.id,
|
||||
// relDepth: depth - i,
|
||||
// };
|
||||
// termAncestriesData.push(ancestryData);
|
||||
// console.log(`准备好的闭包表数据ATermAncestryData:`, ancestryData);
|
||||
// }
|
||||
// const newAncestors = [...ancestors, term.id];
|
||||
// gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
// }
|
||||
// } else {
|
||||
// gatherAncestryData(
|
||||
// node.children,
|
||||
// [createdTerms.find((term) => term.name === node.value).id],
|
||||
// depth + 1,
|
||||
// );
|
||||
// }
|
||||
// currentIndex++;
|
||||
// }
|
||||
};
|
||||
gatherAncestryData(root.children);
|
||||
|
||||
this.logger.log('准备好闭包表数据 Ancestries Data:', termAncestriesData);
|
||||
try {
|
||||
const updatePromises = termsUpdate.map((update) =>
|
||||
tx.term.update(update),
|
||||
);
|
||||
await Promise.all(updatePromises);
|
||||
await tx.termAncestry.createMany({ data: termAncestriesData });
|
||||
const allTerm = await tx.term.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdTerms.map((termt) => termt.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, deletedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const term of allTerm) {
|
||||
await tx.term.update({
|
||||
where: {
|
||||
id: term.id,
|
||||
},
|
||||
data: {
|
||||
hasChildren: term.children.length > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await deleteByPattern('row-*');
|
||||
return { count: createdTerms.length };
|
||||
} catch (error) {
|
||||
console.error('Error 更新Term或者创建Terms闭包表失败:', error);
|
||||
throw new Error('更新术语信息或者创建术语闭包表失败');
|
||||
}
|
||||
});
|
||||
//prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值
|
||||
const termAncestries = await db.termAncestry.findMany({
|
||||
include: {
|
||||
ancestor: true,
|
||||
descendant: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async importDepts(
|
||||
staff: Staff,
|
||||
data: z.infer<typeof TransformMethodSchema.importDepts>,
|
||||
) {
|
||||
const { base64, domainId, parentId } = data;
|
||||
|
||||
// this.logger.log('开始', parentId);
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
const { tree: root } = await this.generateTreeFromFile(buffer);
|
||||
|
||||
this.printTree(root);
|
||||
|
||||
const deptsData: Prisma.DepartmentCreateManyInput[] = [];
|
||||
const deptAncestriesData: Prisma.DeptAncestryCreateManyInput[] = [];
|
||||
const count = await tx.department.count({ where: {} });
|
||||
let deptIndex = 0;
|
||||
// this.logger.log(count);
|
||||
const gatherDeptsData = async (
|
||||
nodes: TreeNode[],
|
||||
depth = 0,
|
||||
dept?: string,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
for (const node of nodes) {
|
||||
const deptData = {
|
||||
name: node.value,
|
||||
// taxonomyId: taxonomyId,
|
||||
domainId: domainId,
|
||||
// createdBy: staff.id,
|
||||
|
||||
order: count + deptIndex + 1,
|
||||
};
|
||||
deptsData.push(deptData);
|
||||
deptIndex++;
|
||||
// Debug: Log term data preparation
|
||||
|
||||
await gatherDeptsData(node.children, depth + 1);
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
await gatherDeptsData(root.children);
|
||||
let createdDepts: { id: string; name: string }[] = [];
|
||||
try {
|
||||
createdDepts = await tx.department.createManyAndReturn({
|
||||
data: deptsData,
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
// Debug: Log created terms
|
||||
} catch (error) {
|
||||
console.error('创建Depts报错:', error);
|
||||
throw new Error('创建失败');
|
||||
}
|
||||
const deptsUpdate = [];
|
||||
const gatherAncestryData = (
|
||||
nodes: TreeNode[],
|
||||
ancestors: string[] = parentId ? [null, parentId] : [null],
|
||||
depth = 0,
|
||||
) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
// if (depth !== 0) {
|
||||
const dept = createdDepts.find((dept) => dept.name === node.value);
|
||||
if (dept) {
|
||||
deptsUpdate.push({
|
||||
where: { id: dept.id },
|
||||
data: { parentId: ancestors[ancestors.length - 1] },
|
||||
});
|
||||
|
||||
for (let i = 0; i < ancestors.length; i++) {
|
||||
const ancestryData = {
|
||||
ancestorId: ancestors[i],
|
||||
descendantId: dept.id,
|
||||
relDepth: depth - i + 1,
|
||||
};
|
||||
deptAncestriesData.push(ancestryData);
|
||||
}
|
||||
const newAncestors = [...ancestors, dept.id];
|
||||
gatherAncestryData(node.children, newAncestors, depth + 1);
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
}
|
||||
};
|
||||
gatherAncestryData(root?.children);
|
||||
|
||||
this.logger.log('准备好闭包表数据 Ancestries Data:', deptAncestriesData);
|
||||
try {
|
||||
const updatePromises = deptsUpdate.map((update) =>
|
||||
tx.department.update(update),
|
||||
);
|
||||
await Promise.all(updatePromises);
|
||||
await tx.deptAncestry.createMany({ data: deptAncestriesData });
|
||||
const allDept = await tx.department.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: createdDepts.map((dept) => dept.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, deletedAt: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const dept of allDept) {
|
||||
await tx.department.update({
|
||||
where: {
|
||||
id: dept.id,
|
||||
},
|
||||
data: {
|
||||
hasChildren: dept.children.length > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await deleteByPattern('row-*');
|
||||
return { count: createdDepts.length };
|
||||
} catch (error) {
|
||||
console.error('Error 更新Dept或者创建Depts闭包表失败:', error);
|
||||
throw new Error('更新单位信息或者创建单位闭包表失败');
|
||||
}
|
||||
});
|
||||
//prisma的特性,create之后填入了对应id,需要做一次这个查询才会填入相应值
|
||||
// const deptAncestries = db.deptAncestry.findMany({
|
||||
// include: {
|
||||
// ancestor: true,
|
||||
// descendant: true,
|
||||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { VisitService } from './visit.service';
|
||||
import { VisitRouter } from './visit.router';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
@Module({
|
||||
providers: [VisitService, VisitRouter, TrpcService],
|
||||
exports: [VisitRouter]
|
||||
})
|
||||
export class VisitModule { }
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { ChangedRows, ObjectType, Prisma } from '@nicestack/common';
|
||||
|
||||
import { VisitService } from './visit.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const VisitCreateArgsSchema: ZodType<Prisma.VisitCreateArgs> = z.any()
|
||||
const VisitCreateManyInputSchema: ZodType<Prisma.VisitCreateManyInput> = z.any()
|
||||
const VisitDeleteManyArgsSchema: ZodType<Prisma.VisitDeleteManyArgs> = z.any()
|
||||
@Injectable()
|
||||
export class VisitRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly visitService: VisitService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(VisitCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.visitService.create(input, staff);
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(VisitCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.visitService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(VisitDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.visitService.deleteMany(input);
|
||||
}),
|
||||
|
||||
|
||||
});
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
ObjectType,
|
||||
Prisma,
|
||||
VisitType,
|
||||
} from '@nicestack/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, troubleId, messageId } = args.data;
|
||||
const visitorId = args.data.visitorId || staff?.id;
|
||||
let result;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
visitType: args.data.visitType,
|
||||
visitorId,
|
||||
OR: [{ postId }, { troubleId }, { messageId }],
|
||||
},
|
||||
});
|
||||
if (!existingVisit) {
|
||||
result = await super.create(args);
|
||||
} else if (args.data.visitType === VisitType.READED) {
|
||||
result = await super.update({
|
||||
where: { id: existingVisit.id },
|
||||
data: {
|
||||
...args.data,
|
||||
views: existingVisit.views + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (troubleId && args.data.visitType === 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 = [];
|
||||
const createData = [];
|
||||
await Promise.all(
|
||||
data.map(async (item) => {
|
||||
item.visitorId = item.visitorId || staff?.id;
|
||||
const { postId, troubleId, messageId, visitorId } = item;
|
||||
const existingVisit = await db.visit.findFirst({
|
||||
where: {
|
||||
visitorId,
|
||||
OR: [{ postId }, { troubleId }, { 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
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
import {
|
||||
QueueEventsListener,
|
||||
QueueEventsHost,
|
||||
OnQueueEvent,
|
||||
InjectQueue,
|
||||
} from '@nestjs/bullmq';
|
||||
import { SocketGateway } from '@server/socket/socket.gateway';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
|
||||
@QueueEventsListener('general')
|
||||
export class GeneralQueueEvents extends QueueEventsHost {
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue, private socketGateway: SocketGateway) {
|
||||
super()
|
||||
}
|
||||
|
||||
@OnQueueEvent('completed')
|
||||
async onCompleted({
|
||||
jobId,
|
||||
returnvalue
|
||||
}: {
|
||||
jobId: string;
|
||||
returnvalue: string;
|
||||
prev?: string;
|
||||
}) {
|
||||
|
||||
}
|
||||
@OnQueueEvent("progress")
|
||||
async onProgress({ jobId, data }: { jobId: string, data: any }) {
|
||||
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { SocketGateway } from '@server/socket/socket.gateway';
|
||||
@Injectable()
|
||||
export class GeneralQueueService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeneralQueueService.name,)
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue, private socketGateway: SocketGateway) { }
|
||||
onModuleInit() {
|
||||
this.logger.log(`general queue service init at pid=${process.pid}`)
|
||||
|
||||
}
|
||||
async addJob(data: any) {
|
||||
this.logger.log('add embedding job', data.title)
|
||||
|
||||
await this.generalQueue.add('embedding', data, { debounce: { id: data.id } });
|
||||
|
||||
}
|
||||
async getWaitingJobs() {
|
||||
const waitingJobs = await this.generalQueue.getJobs(["waiting"])
|
||||
return waitingJobs
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import axios, { AxiosInstance } from 'axios';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class GeneralService {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private logger: Logger;
|
||||
|
||||
constructor() {
|
||||
const PYTHON_ENDPOINT = process.env.PYTHON_URL || 'http://localhost:8000';
|
||||
this.logger = new Logger(GeneralService.name);
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: PYTHON_ENDPOINT,
|
||||
timeout: 120000, // 设置请求超时时间
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export type CustomJobType = "pushMessage" | "updateTroubleViewCount"
|
||||
export type updateViewCountJobData = {
|
||||
id: string
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { Queue } from "bullmq";
|
||||
import { db, getUniqueItems, MessageDto, ObjectType } from "@nicestack/common"
|
||||
import { MessageContent } from "./push.service";
|
||||
import EventBus, { CrudOperation } from "@server/utils/event-bus";
|
||||
export interface PushMessageJobData {
|
||||
id: string
|
||||
registerToken: string
|
||||
messageContent: MessageContent
|
||||
}
|
||||
@Injectable()
|
||||
export class PushQueueService implements OnModuleInit {
|
||||
private readonly logger = new Logger(PushQueueService.name)
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue) { }
|
||||
onModuleInit() {
|
||||
EventBus.on("dataChanged", async ({ data, type, operation }) => {
|
||||
if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
||||
const message = data as Partial<MessageDto>
|
||||
const uniqueStaffs = getUniqueItems(message.receivers, "id")
|
||||
uniqueStaffs.forEach(item => {
|
||||
const token = item.registerToken
|
||||
if (token) {
|
||||
this.addPushMessageJob({
|
||||
registerToken: token,
|
||||
messageContent: {
|
||||
data: {
|
||||
title: message.title,
|
||||
content: message.content,
|
||||
click_action: {
|
||||
intent: message.intent,
|
||||
url: message.url
|
||||
}
|
||||
},
|
||||
option: message.option as any
|
||||
},
|
||||
id: message.id
|
||||
})
|
||||
} else {
|
||||
this.logger.warn(`用户 ${item.username} 尚未注册registerToken取消消息推送`)
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
async addPushMessageJob(data: PushMessageJobData) {
|
||||
this.logger.log("add push message task", data.registerToken)
|
||||
await this.generalQueue.add('pushMessage', data, { debounce: { id: data.id } })
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import { Injectable, InternalServerErrorException, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
interface LoginResponse {
|
||||
retcode: string;
|
||||
message: string;
|
||||
authtoken?: string;
|
||||
}
|
||||
|
||||
interface MessagePushResponse {
|
||||
retcode: string;
|
||||
message: string;
|
||||
messageid?: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
title: string; // 通知标题(不超过128字节) / Title of notification (upper limit is 128 bytes)
|
||||
content?: string; // 通知内容(不超过256字节) / Content of notification (upper limit is 256 bytes)
|
||||
click_action?: {
|
||||
url?: string; // 点击通知栏消息,打开指定的URL地址 / URL to open when notification is clicked
|
||||
intent?: string; // 点击通知栏消息,用户收到通知栏消息后点击通知栏消息打开应用定义的这个Intent页面 / Intent page to open in the app when notification is clicked
|
||||
};
|
||||
}
|
||||
|
||||
interface Option {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface MessageContent {
|
||||
data: Notification;
|
||||
option?: Option;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushService {
|
||||
private readonly baseURL = process.env.PUSH_URL;
|
||||
private readonly appid = process.env.PUSH_APPID;
|
||||
private readonly appsecret = process.env.PUSH_APPSECRET;
|
||||
private authToken: string | null = null;
|
||||
async login(): Promise<LoginResponse> {
|
||||
if (this.authToken) {
|
||||
return { retcode: '200', message: 'Already logged in', authtoken: this.authToken };
|
||||
}
|
||||
const url = `${this.baseURL}/push/1.0/login`;
|
||||
const response: AxiosResponse<LoginResponse> = await axios.post(url, {
|
||||
appid: this.appid,
|
||||
appsecret: this.appsecret,
|
||||
});
|
||||
this.handleError(response.data.retcode);
|
||||
|
||||
this.authToken = response.data.authtoken;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async messagePush(
|
||||
registerToken: string,
|
||||
messageContent: MessageContent,
|
||||
): Promise<MessagePushResponse> {
|
||||
if (!this.authToken) {
|
||||
await this.login();
|
||||
}
|
||||
const url = `${this.baseURL}/push/1.0/messagepush`;
|
||||
const payload = {
|
||||
appid: this.appid,
|
||||
appsecret: this.appsecret,
|
||||
authtoken: this.authToken,
|
||||
registertoken: registerToken,
|
||||
messagecontent: JSON.stringify(messageContent),
|
||||
};
|
||||
const response: AxiosResponse<MessagePushResponse> = await axios.post(url, payload);
|
||||
this.handleError(response.data.retcode);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private handleError(retcode: string): void {
|
||||
switch (retcode) {
|
||||
case '000':
|
||||
case '200':
|
||||
return;
|
||||
case '001':
|
||||
throw new BadRequestException('JID is illegal');
|
||||
case '002':
|
||||
throw new BadRequestException('AppID is illegal');
|
||||
case '003':
|
||||
throw new BadRequestException('Protoversion is mismatch');
|
||||
case '010':
|
||||
throw new BadRequestException('The application of AppID Token is repeated');
|
||||
case '011':
|
||||
throw new BadRequestException('The number of applications for token exceeds the maximum');
|
||||
case '012':
|
||||
throw new BadRequestException('Token is illegal');
|
||||
case '013':
|
||||
throw new UnauthorizedException('Integrity check failed');
|
||||
case '014':
|
||||
throw new BadRequestException('Parameter is illegal');
|
||||
case '015':
|
||||
throw new InternalServerErrorException('Internal error');
|
||||
case '202':
|
||||
throw new UnauthorizedException('You are already logged in');
|
||||
case '205':
|
||||
return;
|
||||
case '500':
|
||||
throw new InternalServerErrorException('Internal Server Error');
|
||||
case '502':
|
||||
throw new InternalServerErrorException('Session loading error');
|
||||
case '503':
|
||||
throw new InternalServerErrorException('Service Unavailable');
|
||||
case '504':
|
||||
throw new NotFoundException('Parameters not found');
|
||||
case '505':
|
||||
throw new BadRequestException('Parameters are empty or not as expected');
|
||||
case '506':
|
||||
throw new InternalServerErrorException('Database error');
|
||||
case '508':
|
||||
throw new InternalServerErrorException('NoSuchAlgorithmException');
|
||||
case '509':
|
||||
throw new UnauthorizedException('Authentication Failed');
|
||||
case '510':
|
||||
throw new UnauthorizedException('Illegal token... client does not exist');
|
||||
default:
|
||||
throw new InternalServerErrorException('Unexpected error occurred');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +1,31 @@
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { join } from 'path';
|
||||
import { SocketGateway } from '@server/socket/socket.gateway';
|
||||
import { PushService } from './push/push.service';
|
||||
import { PushQueueService } from './push/push.queue.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
},
|
||||
}), BullModule.registerQueue({
|
||||
name: 'general',
|
||||
processors: [join(__dirname, 'worker/processor.js')],
|
||||
})
|
||||
],
|
||||
providers: [Logger, SocketGateway],
|
||||
exports: []
|
||||
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')],
|
||||
})
|
||||
],
|
||||
providers: [Logger, PushService, PushQueueService],
|
||||
exports: [PushService, PushQueueService]
|
||||
|
||||
})
|
||||
export class QueueModule { }
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
import { Job } from 'bullmq';
|
||||
export default async function (job: Job<any, any, any>) {
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CustomJobType } from '../job.interface';
|
||||
import { PushService } from '@server/queue/push/push.service';
|
||||
|
||||
const logger = new Logger("QueueWorker");
|
||||
|
||||
const pushService = new PushService()
|
||||
export default async function (job: Job<any, any, CustomJobType>) {
|
||||
|
||||
switch (job.name) {
|
||||
case "pushMessage":
|
||||
|
||||
logger.log(`push message ${job.data.id}`)
|
||||
pushService.messagePush(job.data.registerToken, job.data.messageContent)
|
||||
break
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { TrpcService } from "@server/trpc/trpc.service";
|
||||
import { RoleService } from "./role.service";
|
||||
import { z, RoleSchema } from "@nicestack/common";
|
||||
|
||||
@Injectable()
|
||||
export class RoleRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleService: RoleService
|
||||
) { }
|
||||
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure.input(RoleSchema.create).mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.create(input);
|
||||
}),
|
||||
batchDelete: this.trpc.protectProcedure.input(RoleSchema.batchDelete).mutation(async ({ input }) => {
|
||||
return await this.roleService.batchDelete(input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure.input(RoleSchema.update).mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.update(input);
|
||||
}),
|
||||
paginate: this.trpc.protectProcedure.input(RoleSchema.paginate).query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleService.paginate(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(RoleSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleService.findMany(input);
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, z, RoleSchema, ObjectType, Role, RoleMap } from "@nicestack/common";
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
@Injectable()
|
||||
export class RoleService {
|
||||
constructor(
|
||||
private readonly departmentService: DepartmentService
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* @param data 包含创建角色所需信息的数据
|
||||
* @returns 创建的角色
|
||||
*/
|
||||
async create(data: z.infer<typeof RoleSchema.create>) {
|
||||
|
||||
// 开启事务
|
||||
return await db.$transaction(async (prisma) => {
|
||||
// 创建角色
|
||||
return await prisma.role.create({ data });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
* @param data 包含更新角色所需信息的数据
|
||||
* @returns 更新后的角色
|
||||
*/
|
||||
async update(data: z.infer<typeof RoleSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
|
||||
// 开启事务
|
||||
return await db.$transaction(async (prisma) => {
|
||||
// 更新角色
|
||||
const updatedRole = await prisma.role.update({
|
||||
where: { id },
|
||||
data: { ...others }
|
||||
});
|
||||
|
||||
return updatedRole;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除角色
|
||||
* @param data 包含要删除的角色ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async batchDelete(data: z.infer<typeof RoleSchema.batchDelete>) {
|
||||
const { ids } = data;
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.'
|
||||
});
|
||||
}
|
||||
|
||||
// 开启事务
|
||||
return await db.$transaction(async (prisma) => {
|
||||
const deletedRoles = await prisma.role.updateMany({
|
||||
where: {
|
||||
id: { in: ids }
|
||||
},
|
||||
data: { deletedAt: new Date() }
|
||||
});
|
||||
|
||||
await prisma.roleMap.deleteMany({
|
||||
where: {
|
||||
roleId: {
|
||||
in: ids
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!deletedRoles.count) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No roles were found with the provided IDs.'
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, count: deletedRoles.count };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取角色
|
||||
* @param data 包含分页信息的数据
|
||||
* @returns 分页结果,包含角色列表和总数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof RoleSchema.paginate>) {
|
||||
const { page, pageSize } = data;
|
||||
const [items, totalCount] = await Promise.all([
|
||||
db.role.findMany({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { name: "asc" },
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
roleMaps: true,
|
||||
}
|
||||
}),
|
||||
db.role.count({ where: { deletedAt: null } }),
|
||||
]);
|
||||
const result = { items, totalCount };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关键字查找多个角色
|
||||
* @param data 包含关键字的数据
|
||||
* @returns 查找到的角色列表
|
||||
*/
|
||||
async findMany(data: z.infer<typeof RoleSchema.findMany>) {
|
||||
const { keyword } = data
|
||||
return await db.role.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: keyword
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 10
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import { RoleMapSchema } from '@nicestack/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(RoleMapSchema.deleteWithObject)
|
||||
.mutation(({ input }) =>
|
||||
this.roleMapService.deleteAllRolesForObject(input),
|
||||
),
|
||||
setRoleForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.create)
|
||||
.mutation(({ input }) => this.roleMapService.setRoleForObject(input)),
|
||||
createManyObjects: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.createManyObjects)
|
||||
.mutation(({ input }) => this.roleMapService.createManyObjects(input)),
|
||||
setRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.createManyRoles)
|
||||
.mutation(({ input }) => this.roleMapService.setRolesForObject(input)),
|
||||
|
||||
getPermsForObject: this.trpc.procedure
|
||||
.input(RoleMapSchema.getPermsForObject)
|
||||
.query(({ input }) => this.roleMapService.getPermsForObject(input)),
|
||||
batchDelete: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.batchDelete) // Assuming RoleMapSchema.batchDelete is the Zod schema for batch deleting staff
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.roleMapService.batchDelete(input);
|
||||
}),
|
||||
|
||||
paginate: this.trpc.procedure
|
||||
.input(RoleMapSchema.paginate) // Define the input schema for pagination
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.paginate(input);
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(RoleMapSchema.update)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.roleMapService.update(input);
|
||||
}),
|
||||
getRoleMapDetail: this.trpc.procedure
|
||||
.input(RoleMapSchema.getRoleMapDetail)
|
||||
.query(async ({ input }) => {
|
||||
return await this.roleMapService.getRoleMapDetail(input);
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -1,215 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { db, z, RoleMapSchema, ObjectType } from '@nicestack/common';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
@Injectable()
|
||||
export class RoleMapService {
|
||||
constructor(private readonly departmentService: DepartmentService) { }
|
||||
|
||||
/**
|
||||
* 删除某对象的所有角色
|
||||
* @param data 包含对象ID的数据
|
||||
* @returns 删除结果
|
||||
*/
|
||||
async deleteAllRolesForObject(
|
||||
data: z.infer<typeof RoleMapSchema.deleteWithObject>,
|
||||
) {
|
||||
const { objectId } = data;
|
||||
return await db.roleMap.deleteMany({
|
||||
where: {
|
||||
objectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某对象设置一个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射
|
||||
*/
|
||||
async setRoleForObject(data: z.infer<typeof RoleMapSchema.create>) {
|
||||
return await db.roleMap.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为多个对象创建角色映射
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async createManyObjects(
|
||||
data: z.infer<typeof RoleMapSchema.createManyObjects>,
|
||||
) {
|
||||
const { domainId, roleId, objectIds, objectType } = data;
|
||||
const roleMaps = objectIds.map((id) => ({
|
||||
domainId,
|
||||
objectId: id,
|
||||
roleId,
|
||||
objectType,
|
||||
}));
|
||||
|
||||
// 开启事务
|
||||
return await db.$transaction(async (prisma) => {
|
||||
// 首先,删除现有的角色映射
|
||||
await prisma.roleMap.deleteMany({
|
||||
where: {
|
||||
domainId,
|
||||
roleId,
|
||||
objectType,
|
||||
},
|
||||
});
|
||||
// 然后,创建新的角色映射
|
||||
return await prisma.roleMap.createMany({
|
||||
data: roleMaps,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某对象设置多个角色
|
||||
* @param data 角色映射数据
|
||||
* @returns 创建的角色映射列表
|
||||
*/
|
||||
async setRolesForObject(data: z.infer<typeof RoleMapSchema.createManyRoles>) {
|
||||
const { domainId, objectId, roleIds, objectType } = data;
|
||||
const roleMaps = roleIds.map((id) => ({
|
||||
domainId,
|
||||
objectId,
|
||||
roleId: id,
|
||||
objectType,
|
||||
}));
|
||||
|
||||
return await db.roleMap.createMany({ data: roleMaps });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某对象的权限
|
||||
* @param data 包含域ID、部门ID和对象ID的数据
|
||||
* @returns 用户角色的权限列表
|
||||
*/
|
||||
async getPermsForObject(
|
||||
data: z.infer<typeof RoleMapSchema.getPermsForObject>,
|
||||
) {
|
||||
const { domainId, deptId, staffId } = data;
|
||||
|
||||
let ancestorDeptIds = [];
|
||||
if (deptId) {
|
||||
ancestorDeptIds =
|
||||
await this.departmentService.getAllParentDeptIds(deptId);
|
||||
}
|
||||
|
||||
const userRoles = await db.roleMap.findMany({
|
||||
where: {
|
||||
AND: {
|
||||
domainId,
|
||||
OR: [
|
||||
{
|
||||
objectId: staffId,
|
||||
objectType: ObjectType.STAFF
|
||||
},
|
||||
(deptId ? {
|
||||
objectId: { in: [deptId, ...ancestorDeptIds] },
|
||||
objectType: ObjectType.DEPARTMENT,
|
||||
} : {}),
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
|
||||
return userRoles.flatMap((userRole) => userRole.role.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除角色映射
|
||||
* @param data 包含要删除的角色映射ID列表的数据
|
||||
* @returns 删除结果
|
||||
* @throws 如果未提供ID,将抛出错误
|
||||
*/
|
||||
async batchDelete(data: z.infer<typeof RoleMapSchema.batchDelete>) {
|
||||
const { ids } = data;
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'No IDs provided for deletion.',
|
||||
});
|
||||
}
|
||||
|
||||
const deletedRoleMaps = await db.roleMap.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
if (!deletedRoleMaps.count) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No taxonomies were found with the provided IDs.',
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, count: deletedRoleMaps.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页获取角色映射
|
||||
* @param data 包含分页信息的数据
|
||||
* @returns 分页结果,包含角色映射列表和总数
|
||||
*/
|
||||
async paginate(data: z.infer<typeof RoleMapSchema.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新角色映射
|
||||
* @param data 包含更新信息的数据
|
||||
* @returns 更新后的角色映射
|
||||
*/
|
||||
async update(data: z.infer<typeof RoleMapSchema.update>) {
|
||||
const { id, ...others } = data;
|
||||
|
||||
// 开启事务
|
||||
return await db.$transaction(async (prisma) => {
|
||||
// 更新角色映射
|
||||
const updatedRoleMap = await prisma.roleMap.update({
|
||||
where: { id },
|
||||
data: { ...others },
|
||||
});
|
||||
return updatedRoleMap;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色映射详情
|
||||
* @param data 包含角色ID和域ID的数据
|
||||
* @returns 角色映射详情,包含部门ID和员工ID列表
|
||||
*/
|
||||
async getRoleMapDetail(data: z.infer<typeof RoleMapSchema.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 };
|
||||
}
|
||||
}
|
|
@ -1,189 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Prisma,
|
||||
ObjectType,
|
||||
RolePerms,
|
||||
RelationType,
|
||||
db,
|
||||
Staff,
|
||||
Term,
|
||||
GenPerms,
|
||||
} from '@nicestack/common';
|
||||
import { DepartmentService } from '@server/models/department/department.service';
|
||||
import { RelationService } from '@server/relation/relation.service';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
type RolePermsHandlers = {
|
||||
[key in RolePerms]?: (permissions: GenPerms) => void;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RolePermsService {
|
||||
constructor(
|
||||
private readonly relations: RelationService,
|
||||
private readonly departments: DepartmentService,
|
||||
private readonly rbac: RoleMapService,
|
||||
) { }
|
||||
private readonly logger = new Logger(RolePermsService.name);
|
||||
async getStaffPerms(staff: Staff) {
|
||||
const staffPerms = await this.rbac.getPermsForObject({
|
||||
domainId: staff.domainId,
|
||||
staffId: staff.id,
|
||||
deptId: staff.deptId,
|
||||
});
|
||||
return staffPerms;
|
||||
}
|
||||
async getTermPerms(staff: Staff, term: Term) {
|
||||
const termPerms: GenPerms = {
|
||||
delete: false,
|
||||
edit: false,
|
||||
read: false,
|
||||
};
|
||||
const staffPerms = await this.getStaffPerms(staff)
|
||||
const isInDomain = staff.domainId === term.domainId;
|
||||
const watchDeptIds = await this.relations.getEROBids(
|
||||
ObjectType.TERM,
|
||||
RelationType.WATCH,
|
||||
ObjectType.DEPARTMENT,
|
||||
term.id,
|
||||
);
|
||||
const watchStaffIds = await this.relations.getEROBids(
|
||||
ObjectType.TERM,
|
||||
RelationType.WATCH,
|
||||
ObjectType.STAFF,
|
||||
term.id,
|
||||
);
|
||||
const canWatch =
|
||||
watchDeptIds.includes(staff.deptId) || watchStaffIds.includes(staff.id);
|
||||
if (canWatch) {
|
||||
Object.assign(termPerms, { read: true });
|
||||
}
|
||||
const applyRolePerms = (perm: RolePerms) => {
|
||||
const handlers: RolePermsHandlers = {
|
||||
[RolePerms.EDIT_ANY_TERM]: (p) => Object.assign(p, { edit: true }),
|
||||
[RolePerms.EDIT_DOM_TERM]: (p) =>
|
||||
isInDomain && Object.assign(p, { edit: true }),
|
||||
[RolePerms.READ_DOM_TERM]: (p) =>
|
||||
isInDomain && Object.assign(p, { read: true }),
|
||||
[RolePerms.READ_ANY_TERM]: (p) => Object.assign(p, { read: true }),
|
||||
[RolePerms.DELETE_ANY_TERM]: (p) => Object.assign(p, { delete: true }),
|
||||
[RolePerms.DELETE_DOM_TERM]: (p) =>
|
||||
isInDomain && Object.assign(p, { delete: true }),
|
||||
};
|
||||
handlers[perm]?.(termPerms);
|
||||
};
|
||||
staffPerms.forEach(applyRolePerms);
|
||||
return termPerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build conditions for querying message comments.
|
||||
* @param staff - The staff details to build conditions.
|
||||
* @returns A string representing the SQL condition for message comments.
|
||||
*/
|
||||
async buildCommentExtraQuery(
|
||||
staff: Staff,
|
||||
aId: string,
|
||||
aType: ObjectType,
|
||||
relationType: RelationType,
|
||||
): Promise<string> {
|
||||
const { id: staffId, deptId } = staff;
|
||||
const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId);
|
||||
let queryString = '';
|
||||
if (relationType === RelationType.MESSAGE) {
|
||||
queryString = `
|
||||
c.id IN (
|
||||
SELECT "aId"
|
||||
FROM relations
|
||||
WHERE (
|
||||
"bId" = '${staffId}' AND
|
||||
"bType" = '${ObjectType.STAFF}' AND
|
||||
"aType" = '${ObjectType.COMMENT}' AND
|
||||
"relationType" = '${RelationType.MESSAGE}'
|
||||
)
|
||||
`;
|
||||
|
||||
if (ancestorDeptIds.length > 0) {
|
||||
queryString += `
|
||||
OR (
|
||||
"bId" IN (${[...ancestorDeptIds, deptId].map((id) => `'${id}'`).join(', ')}) AND
|
||||
"bType" = '${ObjectType.DEPARTMENT}' AND
|
||||
"aType" = '${ObjectType.COMMENT}' AND
|
||||
"relationType" = '${RelationType.MESSAGE}'
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
queryString += `)`;
|
||||
} else {
|
||||
queryString = `
|
||||
c.id IN (
|
||||
SELECT "bId"
|
||||
FROM relations
|
||||
WHERE (
|
||||
"aId" = '${aId}' AND
|
||||
"aType" = '${aType}' AND
|
||||
"bType" = '${ObjectType.COMMENT}' AND
|
||||
"relationType" = '${relationType}'
|
||||
)
|
||||
`;
|
||||
queryString += `)`;
|
||||
}
|
||||
|
||||
return queryString;
|
||||
}
|
||||
async getTermExtraConditions(staff: Staff) {
|
||||
const { domainId, id: staffId, deptId } = staff;
|
||||
const staffPerms = await this.getStaffPerms(staff)
|
||||
|
||||
const ancestorDeptIds = await this.departments.getAllParentDeptIds(deptId);
|
||||
|
||||
if (staffPerms.includes(RolePerms.READ_ANY_TERM)) {
|
||||
return {};
|
||||
}
|
||||
const relevantRelations = await db.relation.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
bId: staffId,
|
||||
bType: ObjectType.STAFF,
|
||||
aType: ObjectType.TERM,
|
||||
relationType: RelationType.WATCH,
|
||||
},
|
||||
{
|
||||
bId: { in: ancestorDeptIds },
|
||||
bType: ObjectType.DEPARTMENT,
|
||||
aType: ObjectType.TERM,
|
||||
relationType: RelationType.WATCH,
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { aId: true },
|
||||
});
|
||||
|
||||
const termIds = relevantRelations.map((relation) => relation.aId);
|
||||
const ownedTermIds = await db.term.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
createdBy: staffId,
|
||||
},
|
||||
});
|
||||
const conditions: Prisma.TermWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
id: {
|
||||
in: [...termIds, ...ownedTermIds.map((item) => item.id)],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (domainId && staffPerms.includes(RolePerms.READ_DOM_TERM)) {
|
||||
conditions.OR.push({
|
||||
OR: [{ domainId: null }, { domainId: domainId }],
|
||||
});
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
// redis.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
providers: [RedisService, ConfigService], // 注册 RedisService 作为提供者
|
||||
exports: [RedisService], // 导出 RedisService
|
||||
|
||||
})
|
||||
export class RedisModule { }
|
|
@ -1,73 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
private readonly redisClient: Redis;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.redisClient = new Redis({
|
||||
host: configService.get<string>('REDIS_HOST'),
|
||||
port: configService.get<number>('REDIS_PORT'), // Redis 服务器的端口
|
||||
});
|
||||
}
|
||||
|
||||
setValue(key: string, value: string) {
|
||||
return this.redisClient.set(key, value);
|
||||
}
|
||||
getValue(key: string) {
|
||||
return this.redisClient.get(key);
|
||||
|
||||
}
|
||||
keys(pattern: string) {
|
||||
return this.redisClient.keys(pattern)
|
||||
}
|
||||
setWithExpiry(key: string, value: string, time: number) {
|
||||
return this.redisClient.setex(key, time, value);
|
||||
}
|
||||
deleteKey(key: string) {
|
||||
return this.redisClient.del(key);
|
||||
}
|
||||
setHashField(key: string, field: string, value: string) {
|
||||
return this.redisClient.hset(key, field, value);
|
||||
}
|
||||
//获取key中的field字段数据
|
||||
getHashField(key: string, field: string) {
|
||||
return this.redisClient.hget(key, field);
|
||||
}
|
||||
//获取key中所有数据
|
||||
getAllHashFields(key: string) {
|
||||
return this.redisClient.hgetall(key);
|
||||
}
|
||||
publishMessage(channel: string, message: string) {
|
||||
return this.redisClient.publish(channel, message);
|
||||
}
|
||||
|
||||
// 订阅消息,需要提供一个回调函数来处理接收到的消息
|
||||
subscribeToMessages(channel: string, messageHandler: (channel: string, message: string) => void) {
|
||||
this.redisClient.subscribe(channel, (err, count) => {
|
||||
if (err) {
|
||||
console.error('Subscription error', err);
|
||||
} else {
|
||||
console.log(`Subscribed to ${count} channels`);
|
||||
}
|
||||
});
|
||||
|
||||
this.redisClient.on('message', (channel, message) => {
|
||||
console.log(`Received message ${message} from channel ${channel}`);
|
||||
messageHandler(channel, message);
|
||||
});
|
||||
}
|
||||
|
||||
// 取消订阅指定的频道
|
||||
unsubscribeFromChannel(channel: string) {
|
||||
return this.redisClient.unsubscribe(channel);
|
||||
}
|
||||
|
||||
// 取消订阅所有频道
|
||||
unsubscribeAll() {
|
||||
return this.redisClient.quit();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ObjectType, RelationType, db, Relation } from "@nicestack/common";
|
||||
|
||||
/**
|
||||
* Service dealing with relation entities.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RelationService {
|
||||
|
||||
/**
|
||||
* Create a new relation object.
|
||||
*
|
||||
* @param {string} aId - The ID of the related entity.
|
||||
* @param {string} bId - The ID of the target object.
|
||||
* @param {ObjectType} bType - The type of the target object.
|
||||
* @param {RelationType} relationType - The type of the relation.
|
||||
* @returns {{aId: string, bId: string, aType:ObjectType, bType: ObjectType, relationType: RelationType}} An object representing the created relation.
|
||||
*/
|
||||
buildRelation(aId: string, bId: string, aType: ObjectType, bType: ObjectType, relationType: RelationType): { aId: string; bId: string; aType: ObjectType; bType: ObjectType; relationType: RelationType; } {
|
||||
return {
|
||||
aId,
|
||||
bId,
|
||||
aType,
|
||||
bType,
|
||||
relationType
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relations based on entity type, relation type, object type, and entity ID.
|
||||
*
|
||||
* @param {ObjectType} aType - The type of the entity.
|
||||
* @param {RelationType} relationType - The type of the relation.
|
||||
* @param {ObjectType} bType - The type of the object.
|
||||
* @param {string} aId - The ID of the entity to find relations for.
|
||||
* @param {number} [limit] - Optional limit on the number of results.
|
||||
* @returns {Promise<Array>} A promise that resolves to an array of relation objects.
|
||||
*/
|
||||
async getERO(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise<Array<Relation>> {
|
||||
return await db.relation.findMany({
|
||||
where: {
|
||||
aType,
|
||||
relationType,
|
||||
bType,
|
||||
aId
|
||||
},
|
||||
take: limit // Add the limit if provided
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Find relations based on entity type, relation type, object type, and entity ID.
|
||||
*
|
||||
* @param {ObjectType} aType - The type of the entity.
|
||||
* @param {RelationType} relationType - The type of the relation.
|
||||
* @param {ObjectType} bType - The type of the object.
|
||||
* @param {string} aId - The ID of the entity to find relations for.
|
||||
* @param {number} [limit] - Optional limit on the number of results.
|
||||
* @returns {Promise<number>} A promise that resolves to an array of relation objects.
|
||||
*/
|
||||
async getEROCount(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string): Promise<number> {
|
||||
return await db.relation.count({
|
||||
where: {
|
||||
aType,
|
||||
relationType,
|
||||
bType,
|
||||
aId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the IDs of objects related to a specific entity.
|
||||
*
|
||||
* @param {ObjectType} aType - The type of the entity.
|
||||
* @param {RelationType} relationType - The type of the relation.
|
||||
* @param {ObjectType} bType - The type of the object.
|
||||
* @param {string} aId - The ID of the entity to get related object IDs for.
|
||||
* @param {number} [limit] - Optional limit on the number of results.
|
||||
* @returns {Promise<Array<string>>} A promise that resolves to an array of object IDs.
|
||||
*/
|
||||
async getEROBids(aType: ObjectType, relationType: RelationType, bType: ObjectType, aId: string, limit?: number): Promise<Array<string>> {
|
||||
const res = await this.getERO(aType, relationType, bType, aId, limit);
|
||||
return res.map(relation => relation.bId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
|
||||
import { SocketMessage } from '@nicestack/common';
|
||||
|
||||
const DEFAULT_CONFIG: WebSocketServerConfig = {
|
||||
pingInterval: 30000,
|
||||
pingTimeout: 5000,
|
||||
debug: false, // 新增默认调试配置
|
||||
};
|
||||
interface IWebSocketServer {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
broadcast(data: any): void;
|
||||
handleConnection(ws: WSClient): void;
|
||||
handleDisconnection(ws: WSClient): void;
|
||||
}
|
||||
|
||||
export abstract class BaseWebSocketServer implements IWebSocketServer {
|
||||
private _wss: WebSocketServer | null = null;
|
||||
protected clients: Set<WSClient> = new Set();
|
||||
protected timeouts: Map<WSClient, NodeJS.Timeout> = new Map();
|
||||
protected pingIntervalId?: NodeJS.Timeout;
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
protected readonly finalConfig: WebSocketServerConfig;
|
||||
private userClientMap: Map<string, WSClient> = new Map();
|
||||
constructor(
|
||||
protected readonly config: Partial<WebSocketServerConfig> = {}
|
||||
) {
|
||||
this.finalConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
protected debugLog(message: string, ...optionalParams: any[]): void {
|
||||
if (this.finalConfig.debug) {
|
||||
this.logger.debug(message, ...optionalParams);
|
||||
}
|
||||
}
|
||||
public getClientCount() {
|
||||
return this.clients.size
|
||||
}
|
||||
// 暴露 WebSocketServer 实例的只读访问
|
||||
public get wss(): WebSocketServer | null {
|
||||
return this._wss;
|
||||
}
|
||||
|
||||
// 内部使用的 setter
|
||||
protected set wss(value: WebSocketServer | null) {
|
||||
this._wss = value;
|
||||
}
|
||||
|
||||
public abstract get serverType(): WebSocketType;
|
||||
|
||||
public get serverPath(): string {
|
||||
return this.finalConfig.path || `/${this.serverType}`;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this._wss) await this.stop();
|
||||
|
||||
this._wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
path: this.serverPath
|
||||
});
|
||||
|
||||
this.debugLog(`WebSocket server starting on path: ${this.serverPath}`);
|
||||
this.setupServerEvents();
|
||||
this.startPingInterval();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
if (this.pingIntervalId) {
|
||||
clearInterval(this.pingIntervalId);
|
||||
this.pingIntervalId = undefined;
|
||||
}
|
||||
|
||||
this.clients.forEach(client => client.close());
|
||||
this.clients.clear();
|
||||
this.timeouts.clear();
|
||||
|
||||
if (this._wss) {
|
||||
await new Promise(resolve => this._wss!.close(resolve));
|
||||
this._wss = null;
|
||||
}
|
||||
|
||||
this.debugLog(`WebSocket server stopped on path: ${this.serverPath}`);
|
||||
}
|
||||
|
||||
public broadcast(data: SocketMessage): void {
|
||||
this.clients.forEach(client =>
|
||||
client.readyState === WebSocket.OPEN && client.send(JSON.stringify(data))
|
||||
);
|
||||
}
|
||||
public sendToUser(id: string, data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
const client = this.userClientMap.get(id);
|
||||
client?.send(message)
|
||||
}
|
||||
public sendToUsers(ids: string[], data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
ids.forEach(id => {
|
||||
const client = this.userClientMap.get(id);
|
||||
client?.send(message);
|
||||
});
|
||||
}
|
||||
public sendToRoom(roomId: string, data: SocketMessage) {
|
||||
const message = JSON.stringify(data);
|
||||
this.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN && client.roomId === roomId) {
|
||||
client.send(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
protected getRoomClientsCount(roomId?: string): number {
|
||||
if (!roomId) return 0;
|
||||
return Array.from(this.clients).filter(client => client.roomId === roomId).length;
|
||||
}
|
||||
|
||||
public handleConnection(ws: WSClient): void {
|
||||
if (ws.userId) {
|
||||
this.userClientMap.set(ws.userId, ws);
|
||||
}
|
||||
ws.isAlive = true;
|
||||
ws.type = this.serverType;
|
||||
this.clients.add(ws);
|
||||
this.setupClientEvents(ws);
|
||||
|
||||
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||
this.debugLog(`
|
||||
[${this.serverType}] connected
|
||||
userId ${ws.userId}
|
||||
roomId ${ws.roomId}
|
||||
room clients ${roomClientsCount}
|
||||
total clients ${this.clients.size}`);
|
||||
}
|
||||
|
||||
public handleDisconnection(ws: WSClient): void {
|
||||
if (ws.userId) {
|
||||
this.userClientMap.delete(ws.userId);
|
||||
}
|
||||
this.clients.delete(ws);
|
||||
const timeout = this.timeouts.get(ws);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.timeouts.delete(ws);
|
||||
}
|
||||
ws.terminate();
|
||||
|
||||
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||
|
||||
this.debugLog(`
|
||||
[${this.serverType}] disconnected
|
||||
userId ${ws.userId}
|
||||
roomId ${ws.roomId}
|
||||
room clients ${roomClientsCount}
|
||||
total clients ${this.clients.size}`);
|
||||
}
|
||||
protected setupClientEvents(ws: WSClient): void {
|
||||
ws.on('pong', () => this.handlePong(ws))
|
||||
.on('close', () => this.handleDisconnection(ws))
|
||||
.on('error', (error) => {
|
||||
this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error);
|
||||
this.handleDisconnection(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private handlePong(ws: WSClient): void {
|
||||
ws.isAlive = true;
|
||||
const timeout = this.timeouts.get(ws);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.timeouts.delete(ws);
|
||||
}
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.pingIntervalId = setInterval(
|
||||
() => this.pingClients(),
|
||||
this.finalConfig.pingInterval
|
||||
);
|
||||
}
|
||||
|
||||
private pingClients(): void {
|
||||
this.clients.forEach(ws => {
|
||||
if (!ws.isAlive) return this.handleDisconnection(ws);
|
||||
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
const timeout = setTimeout(
|
||||
() => !ws.isAlive && this.handleDisconnection(ws),
|
||||
this.finalConfig.pingTimeout
|
||||
);
|
||||
this.timeouts.set(ws, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
protected setupServerEvents(): void {
|
||||
if (!this._wss) return;
|
||||
this._wss
|
||||
.on('connection', (ws: WSClient) => this.handleConnection(ws))
|
||||
.on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 此模块实现了一个回调处理系统,用于在协同编辑文档发生更改时通知外部服务。
|
||||
* 它支持多种共享数据类型(Array、Map、Text、XML等)的同步,并可以将更新通过HTTP POST请求发送到指定的回调URL。
|
||||
* 主要用于与外部系统集成,实现文档变更的实时通知。
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { parseInt as libParseInt } from 'lib0/number';
|
||||
import { WSSharedDoc } from './ws-shared-doc';
|
||||
|
||||
|
||||
/**
|
||||
* 回调URL配置,从环境变量中获取
|
||||
* 如果环境变量未设置则为null
|
||||
*/
|
||||
const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null;
|
||||
|
||||
/**
|
||||
* 回调超时时间配置,从环境变量中获取
|
||||
* 默认为5000毫秒
|
||||
*/
|
||||
const CALLBACK_TIMEOUT = libParseInt(process.env.CALLBACK_TIMEOUT || '5000');
|
||||
|
||||
/**
|
||||
* 需要监听变更的共享对象配置
|
||||
* 从环境变量CALLBACK_OBJECTS中解析JSON格式的配置
|
||||
*/
|
||||
const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {};
|
||||
|
||||
/**
|
||||
* 导出回调URL是否已配置的标志
|
||||
*/
|
||||
export const isCallbackSet = !!CALLBACK_URL;
|
||||
|
||||
/**
|
||||
* 定义要发送的数据结构接口
|
||||
*/
|
||||
interface DataToSend {
|
||||
room: string; // 房间/文档标识
|
||||
data: Record<string, {
|
||||
type: string; // 数据类型
|
||||
content: any; // 数据内容
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义更新数据的类型
|
||||
*/
|
||||
type UpdateType = Uint8Array;
|
||||
|
||||
/**
|
||||
* 定义更新来源的类型
|
||||
*/
|
||||
type OriginType = any;
|
||||
|
||||
/**
|
||||
* 处理文档更新的回调函数
|
||||
* @param update - 更新的数据
|
||||
* @param origin - 更新的来源
|
||||
* @param doc - 共享文档实例
|
||||
*/
|
||||
export const callbackHandler = (update: UpdateType, origin: OriginType, doc: WSSharedDoc): void => {
|
||||
// 获取文档名称作为房间标识
|
||||
const room = doc.name;
|
||||
|
||||
// 初始化要发送的数据对象
|
||||
const dataToSend: DataToSend = {
|
||||
room,
|
||||
data: {}
|
||||
};
|
||||
|
||||
// 获取所有需要监听的共享对象名称
|
||||
const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
|
||||
|
||||
// 遍历所有共享对象,获取它们的最新内容
|
||||
sharedObjectList.forEach(sharedObjectName => {
|
||||
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
|
||||
dataToSend.data[sharedObjectName] = {
|
||||
type: sharedObjectType,
|
||||
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON()
|
||||
};
|
||||
});
|
||||
|
||||
// 如果配置了回调URL,则发送HTTP请求
|
||||
if (CALLBACK_URL) {
|
||||
callbackRequest(CALLBACK_URL, CALLBACK_TIMEOUT, dataToSend);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送HTTP回调请求
|
||||
* @param url - 回调的目标URL
|
||||
* @param timeout - 超时时间
|
||||
* @param data - 要发送的数据
|
||||
*/
|
||||
const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
|
||||
// 将数据转换为JSON字符串
|
||||
const dataString = JSON.stringify(data);
|
||||
|
||||
// 配置HTTP请求选项
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
timeout,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(dataString)
|
||||
}
|
||||
};
|
||||
|
||||
// 创建HTTP请求
|
||||
const req = http.request(options);
|
||||
|
||||
// 处理超时事件
|
||||
req.on('timeout', () => {
|
||||
console.warn('Callback request timed out.');
|
||||
req.abort();
|
||||
});
|
||||
|
||||
// 处理错误事件
|
||||
req.on('error', (e) => {
|
||||
console.error('Callback request error.', e);
|
||||
req.abort();
|
||||
});
|
||||
|
||||
// 发送数据
|
||||
req.write(dataString);
|
||||
req.end();
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据对象类型获取共享对象的内容
|
||||
* @param objName - 对象名称
|
||||
* @param objType - 对象类型
|
||||
* @param doc - 共享文档实例
|
||||
* @returns 共享对象的内容
|
||||
*/
|
||||
const getContent = (objName: string, objType: string, doc: WSSharedDoc): any => {
|
||||
// 根据对象类型返回相应的共享对象
|
||||
switch (objType) {
|
||||
case 'Array': return doc.getArray(objName);
|
||||
case 'Map': return doc.getMap(objName);
|
||||
case 'Text': return doc.getText(objName);
|
||||
case 'XmlFragment': return doc.getXmlFragment(objName);
|
||||
case 'XmlElement': return doc.getXmlElement(objName);
|
||||
default: return {};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { YjsServer } from './yjs.server';
|
||||
|
||||
@Module({
|
||||
providers: [YjsServer],
|
||||
exports: [YjsServer]
|
||||
})
|
||||
export class CollaborationModule { }
|
|
@ -0,0 +1,34 @@
|
|||
import { LeveldbPersistence } from 'y-leveldb';
|
||||
import * as Y from 'yjs';
|
||||
import { WSSharedDoc } from './ws-shared-doc';
|
||||
const persistenceDir = process.env.YPERSISTENCE;
|
||||
interface Persistence {
|
||||
bindState: (docName: string, ydoc: WSSharedDoc) => void;
|
||||
writeState: (docName: string, ydoc: WSSharedDoc) => Promise<any>;
|
||||
provider: any;
|
||||
}
|
||||
let persistence: Persistence | null = null;
|
||||
|
||||
if (typeof persistenceDir === 'string') {
|
||||
console.info('Persisting documents to "' + persistenceDir + '"');
|
||||
const ldb = new LeveldbPersistence(persistenceDir);
|
||||
persistence = {
|
||||
provider: ldb,
|
||||
bindState: async (docName, ydoc) => {
|
||||
const persistedYdoc = await ldb.getYDoc(docName);
|
||||
const newUpdates = Y.encodeStateAsUpdate(ydoc);
|
||||
ldb.storeUpdate(docName, newUpdates);
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc));
|
||||
ydoc.on('update', (update: Uint8Array) => {
|
||||
ldb.storeUpdate(docName, update);
|
||||
});
|
||||
},
|
||||
writeState: async (_docName, _ydoc) => { },
|
||||
};
|
||||
}
|
||||
|
||||
export const setPersistence = (persistence_: Persistence | null) => {
|
||||
persistence = persistence_;
|
||||
};
|
||||
|
||||
export const getPersistence = (): Persistence | null => persistence;
|
|
@ -0,0 +1,5 @@
|
|||
export interface ConnectionOptions {
|
||||
docName: string;
|
||||
gc: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { readSyncMessage } from '@nicestack/common';
|
||||
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates, writeSyncStep1, writeUpdate } from '@nicestack/common';
|
||||
import * as encoding from 'lib0/encoding';
|
||||
import * as decoding from 'lib0/decoding';
|
||||
import * as Y from "yjs"
|
||||
import { debounce } from 'lodash';
|
||||
import { getPersistence, setPersistence } from './persistence';
|
||||
import { callbackHandler, isCallbackSet } from './callback';
|
||||
import { WebSocket } from "ws";
|
||||
import { YMessageType } from '@nicestack/common';
|
||||
import { WSClient } from '../types';
|
||||
export const docs = new Map<string, WSSharedDoc>();
|
||||
export const CALLBACK_DEBOUNCE_WAIT = parseInt(process.env.CALLBACK_DEBOUNCE_WAIT || '2000');
|
||||
export const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000');
|
||||
export const getYDoc = (docname: string, gc = true): WSSharedDoc => {
|
||||
return docs.get(docname) || createYDoc(docname, gc);
|
||||
};
|
||||
const createYDoc = (docname: string, gc: boolean): WSSharedDoc => {
|
||||
const doc = new WSSharedDoc(docname, gc);
|
||||
docs.set(docname, doc);
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const send = (doc: WSSharedDoc, conn: WebSocket, m: Uint8Array) => {
|
||||
if (conn.readyState !== WebSocket.OPEN) {
|
||||
closeConn(doc, conn);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
conn.send(m, {}, err => { err != null && closeConn(doc, conn) });
|
||||
} catch (e) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
};
|
||||
export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => {
|
||||
if (doc.conns.has(conn)) {
|
||||
const controlledIds = doc.conns.get(conn) as Set<number>;
|
||||
doc.conns.delete(conn);
|
||||
removeAwarenessStates(
|
||||
doc.awareness,
|
||||
Array.from(controlledIds),
|
||||
null
|
||||
);
|
||||
|
||||
if (doc.conns.size === 0 && getPersistence() !== null) {
|
||||
getPersistence()?.writeState(doc.name, doc).then(() => {
|
||||
doc.destroy();
|
||||
});
|
||||
docs.delete(doc.name);
|
||||
}
|
||||
}
|
||||
conn.close();
|
||||
};
|
||||
|
||||
export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8Array) => {
|
||||
try {
|
||||
const encoder = encoding.createEncoder();
|
||||
const decoder = decoding.createDecoder(message);
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
switch (messageType) {
|
||||
case YMessageType.Sync:
|
||||
// console.log(`received sync message ${message.length}`)
|
||||
encoding.writeVarUint(encoder, YMessageType.Sync);
|
||||
readSyncMessage(decoder, encoder, doc, conn);
|
||||
if (encoding.length(encoder) > 1) {
|
||||
send(doc, conn, encoding.toUint8Array(encoder));
|
||||
}
|
||||
break;
|
||||
|
||||
case YMessageType.Awareness: {
|
||||
applyAwarenessUpdate(
|
||||
doc.awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
conn
|
||||
);
|
||||
// console.log(`received awareness message from ${conn.origin} total ${doc.awareness.states.size}`)
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
doc.emit('error' as any, [err]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateHandler = (update: Uint8Array, _origin: any, doc: WSSharedDoc, _tr: any) => {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, YMessageType.Sync);
|
||||
writeUpdate(encoder, update);
|
||||
const message = encoding.toUint8Array(encoder);
|
||||
doc.conns.forEach((_, conn) => send(doc, conn, message));
|
||||
};
|
||||
|
||||
let contentInitializor: (ydoc: Y.Doc) => Promise<void> = (_ydoc) => Promise.resolve();
|
||||
export const setContentInitializor = (f: (ydoc: Y.Doc) => Promise<void>) => {
|
||||
contentInitializor = f;
|
||||
};
|
||||
|
||||
export class WSSharedDoc extends Y.Doc {
|
||||
name: string;
|
||||
conns: Map<WebSocket, Set<number>>;
|
||||
awareness: Awareness;
|
||||
whenInitialized: Promise<void>;
|
||||
|
||||
constructor(name: string, gc: boolean) {
|
||||
super({ gc });
|
||||
|
||||
this.name = name;
|
||||
this.conns = new Map();
|
||||
this.awareness = new Awareness(this);
|
||||
this.awareness.setLocalState(null);
|
||||
|
||||
const awarenessUpdateHandler = ({
|
||||
added,
|
||||
updated,
|
||||
removed
|
||||
}: {
|
||||
added: number[],
|
||||
updated: number[],
|
||||
removed: number[]
|
||||
}, conn: WebSocket) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
if (changedClients.length === 0) return
|
||||
if (conn !== null) {
|
||||
const connControlledIDs = this.conns.get(conn) as Set<number>;
|
||||
if (connControlledIDs !== undefined) {
|
||||
added.forEach(clientID => { connControlledIDs.add(clientID); });
|
||||
removed.forEach(clientID => { connControlledIDs.delete(clientID); });
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, YMessageType.Awareness);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
encodeAwarenessUpdate(this.awareness, changedClients)
|
||||
);
|
||||
const buff = encoding.toUint8Array(encoder);
|
||||
|
||||
this.conns.forEach((_, c) => {
|
||||
send(this, c, buff);
|
||||
});
|
||||
};
|
||||
|
||||
this.awareness.on('update', awarenessUpdateHandler);
|
||||
this.on('update', updateHandler as any);
|
||||
|
||||
if (isCallbackSet) {
|
||||
this.on('update', debounce(
|
||||
callbackHandler as any,
|
||||
CALLBACK_DEBOUNCE_WAIT,
|
||||
{ maxWait: CALLBACK_DEBOUNCE_MAXWAIT }
|
||||
) as any);
|
||||
}
|
||||
|
||||
this.whenInitialized = contentInitializor(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { WebSocketType, WSClient } from "../types";
|
||||
import { BaseWebSocketServer } from "../base/base-websocket-server";
|
||||
import { encoding } from "lib0";
|
||||
import { YMessageType, writeSyncStep1, encodeAwarenessUpdate } from "@nicestack/common";
|
||||
import { getYDoc, closeConn, WSSharedDoc, messageListener, send } from "./ws-shared-doc";
|
||||
@Injectable()
|
||||
export class YjsServer extends BaseWebSocketServer {
|
||||
public get serverType(): WebSocketType {
|
||||
return WebSocketType.YJS;
|
||||
}
|
||||
public override handleConnection(
|
||||
connection: WSClient
|
||||
): void {
|
||||
super.handleConnection(connection)
|
||||
try {
|
||||
connection.binaryType = 'arraybuffer';
|
||||
const doc = this.initializeDocument(connection, connection.roomId, true);
|
||||
this.setupConnectionHandlers(connection, doc);
|
||||
this.sendInitialSync(connection, doc);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error in handleNewConnection: ${error.message}`, error.stack);
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDocument(conn: WSClient, docName: string, gc: boolean) {
|
||||
const doc = getYDoc(docName, gc);
|
||||
|
||||
doc.conns.set(conn, new Set());
|
||||
return doc;
|
||||
}
|
||||
|
||||
private setupConnectionHandlers(connection: WSClient, doc: WSSharedDoc): void {
|
||||
connection.on('message', (message: ArrayBuffer) => {
|
||||
this.handleMessage(connection, doc, message);
|
||||
});
|
||||
connection.on('close', () => {
|
||||
this.handleClose(doc, connection);
|
||||
});
|
||||
connection.on('error', (error) => {
|
||||
this.logger.error(`WebSocket error for doc ${doc.name}: ${error.message}`, error.stack);
|
||||
closeConn(doc, connection);
|
||||
this.logger.warn(`Connection closed due to error for doc: ${doc.name}. Remaining connections: ${doc.conns.size}`);
|
||||
});
|
||||
}
|
||||
|
||||
private handleClose(doc: WSSharedDoc, connection: WSClient): void {
|
||||
try {
|
||||
closeConn(doc, connection);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error closing connection: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
private handleMessage(connection: WSClient, doc: WSSharedDoc, message: ArrayBuffer): void {
|
||||
try {
|
||||
messageListener(connection, doc, new Uint8Array(message));
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error handling message: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
private sendInitialSync(connection: WSClient, doc: any): void {
|
||||
this.sendSyncStep1(connection, doc);
|
||||
this.sendAwarenessStates(connection, doc);
|
||||
}
|
||||
private sendSyncStep1(connection: WSClient, doc: any): void {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, YMessageType.Sync);
|
||||
writeSyncStep1(encoder, doc);
|
||||
send(doc, connection, encoding.toUint8Array(encoder));
|
||||
}
|
||||
private sendAwarenessStates(connection: WSClient, doc: WSSharedDoc): void {
|
||||
const awarenessStates = doc.awareness.getStates();
|
||||
|
||||
if (awarenessStates.size > 0) {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, YMessageType.Awareness);
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))
|
||||
);
|
||||
send(doc, connection, encoding.toUint8Array(encoder));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RealtimeServer } from './realtime.server';
|
||||
|
||||
|
||||
@Module({
|
||||
providers: [ RealtimeServer],
|
||||
exports: [ RealtimeServer]
|
||||
})
|
||||
export class RealTimeModule { }
|
|
@ -0,0 +1,31 @@
|
|||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { WebSocketType } from "../types";
|
||||
import { BaseWebSocketServer } from "../base/base-websocket-server";
|
||||
import EventBus, { CrudOperation } from "@server/utils/event-bus";
|
||||
import { ObjectType, SocketMsgType, TroubleDto, MessageDto, PostDto, PostType } from "@nicestack/common";
|
||||
@Injectable()
|
||||
export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
EventBus.on("dataChanged", ({ data, type, operation }) => {
|
||||
if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
||||
const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
|
||||
this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
|
||||
}
|
||||
if (type === ObjectType.TROUBLE) {
|
||||
const trouble = data as Partial<TroubleDto>
|
||||
this.sendToRoom('troubles', { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.TROUBLE } })
|
||||
this.sendToRoom(trouble.id, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.TROUBLE } })
|
||||
}
|
||||
if (type === ObjectType.POST) {
|
||||
const post = data as Partial<PostDto>
|
||||
if (post.type === PostType.TROUBLE_INSTRUCTION || post.type === PostType.TROUBLE_PROGRESS) {
|
||||
this.sendToRoom(post.referenceId, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.POST } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
public get serverType(): WebSocketType {
|
||||
return WebSocketType.REALTIME;
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@WebSocketGateway(3001, {
|
||||
namespace: 'library-events',
|
||||
cors: {
|
||||
origin: '*', // 或者你可以指定特定的来源,例如 "http://localhost:3000"
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
}
|
||||
})
|
||||
export class SocketGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
@WebSocketServer() server: Server;
|
||||
|
||||
afterInit(server: Server) {
|
||||
console.log('WebSocket initialized');
|
||||
}
|
||||
|
||||
handleConnection(client: Socket, ...args: any[]) {
|
||||
console.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
console.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { WebSocketServer, WebSocket } from "ws";
|
||||
|
||||
// 类型定义
|
||||
export enum WebSocketType {
|
||||
YJS = "yjs",
|
||||
REALTIME = "realtime"
|
||||
}
|
||||
|
||||
export interface WebSocketServerConfig {
|
||||
path?: string;
|
||||
pingInterval?: number;
|
||||
pingTimeout?: number;
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface ServerInstance {
|
||||
wss: WebSocketServer | null;
|
||||
clients: Set<WSClient>;
|
||||
pingIntervalId?: NodeJS.Timeout;
|
||||
timeouts: Map<WebSocket, NodeJS.Timeout>;
|
||||
}
|
||||
|
||||
export interface WSClient extends WebSocket {
|
||||
isAlive?: boolean;
|
||||
type?: WebSocketType;
|
||||
userId?: string
|
||||
origin?: string
|
||||
roomId?: string
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue