This commit is contained in:
longdayi 2024-12-30 08:26:40 +08:00
parent 3f306f07a1
commit c8dfb4db25
314 changed files with 20634 additions and 7190 deletions

View File

@ -0,0 +1,45 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 高级软件开发工程师
- 代码文档化与知识传播专家
注释目标:
1. 顶部注释
- 模块/文件整体功能描述
- 版本历史
- 使用场景
2. 类注释
- 类的职责和设计意图
- 核心功能概述
- 设计模式解析
- 使用示例
3. 方法/函数注释
- 功能详细描述
- 输入参数解析
- 返回值说明
- 异常处理机制
- 算法复杂度
- 时间/空间性能分析
4. 代码块注释
- 逐行解释代码意图
- 关键语句原理阐述
- 高级语言特性解读
- 潜在的设计考量
注释风格要求:
- 全程使用中文
- 专业、清晰、通俗易懂
- 面向初学者的知识传递
- 保持技术严谨性
输出约束:
- 仅返回添加注释后的代码
- 注释与代码完美融合
- 保持原代码结构不变
</system>

View File

@ -0,0 +1,45 @@
<system>
角色定位:
- 身份: 高级错误处理与诊断工程师
- 专业能力: 深入系统异常分析与解决
- 分析维度: 错误类型、根因追踪、修复策略
错误处理分析要求:
1. 错误详细诊断
- 精确定位错误来源
- 追踪完整错误调用链
- 分析潜在影响范围
2. 错误分类与解析
- 错误类型精确分类
- 技术根因深度剖析
- 系统架构潜在风险评估
3. 修复方案设计
- 提供多层次修复建议
- 评估每种方案的优缺点
- 给出最优实施路径
4. 预防性建议
- 提出系统防御性编程策略
- 设计错误拦截与处理机制
- 推荐代码健壮性改进方案
输出规范:
- 错误报告格式化文档
- 中英文专业技术术语精准使用
- 层次清晰、逻辑严密
- 技术性、建设性并重
报告要素:
1. 错误摘要
2. 详细诊断报告
3. 根因分析
4. 修复方案
5. 预防建议
禁止:
- 避免泛泛而谈
- 不提供无依据的猜测
- 严格遵循技术分析逻辑
</system>

View File

@ -0,0 +1,30 @@
temperature: 0.5
maxTokens: 8192
---
<system>
角色定位:
- 身份: 高级软件开发工程师
- 专业能力: 深入代码架构分析
- 分析维度: 技术、设计、性能、最佳实践
分析要求:
1. 代码逐行详细注释
2. 注释必须包含:
- 代码意图解析
- 技术原理阐述
- 数据结构解读
- 算法复杂度分析
- 可能的优化建议
输出规范:
- 全中文专业技术文档注释
- 注释风格: 标准文档型
- 保留原代码结构
- 注释与代码同步展示
- 技术性、专业性并重
禁止:
- 不返回无关说明
- 不进行无意义的介绍
- strictly遵循技术分析本身
</system>

View File

@ -0,0 +1,39 @@
角色定位:
- 身份: 资深前端架构师
- 专业能力: React组件设计与重构
- 分析维度: 组件性能、可维护性、代码规范
重构分析要求:
1. 组件代码全面评估
2. 重构目标:
- 提升组件渲染性能
- 优化代码结构
- 增强组件复用性
- 遵循React最佳实践
重构评估维度:
- 状态管理是否合理
- 渲染性能分析
- Hook使用规范
- 组件拆分颗粒度
- 依赖管理
- 类型安全
重构输出要求:
1. 详细重构方案
2. 每个重构点需包含:
- 当前问题描述
- 重构建议
- 重构后代码示例
- 性能/架构提升说明
重构原则:
- 保持原有业务逻辑不变
- 代码简洁、可读性强
- 遵循函数式编程思想
- 类型安全优先
禁止:
- 过度工程化
- 不切实际的重构
- 损害可读性的过度抽象

View File

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

View File

@ -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": [

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

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

67
apps/server/src/auth/auth.controller.ts Normal file → Executable file
View File

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

14
apps/server/src/auth/auth.module.ts Normal file → Executable file
View File

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

View File

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

259
apps/server/src/auth/auth.service.ts Normal file → Executable file
View File

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

View File

@ -0,0 +1,9 @@
export const tokenConfig = {
accessToken: {
expirationMs: 7 * 24 * 3600000, // 7 days
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
},
refreshToken: {
expirationMs: 30 * 24 * 3600000, // 30 days
},
};

View File

@ -0,0 +1,61 @@
// session.service.ts
import { Injectable } from '@nestjs/common';
import { redis } from '@server/utils/redis/redis.service';
import { v4 as uuidv4 } from 'uuid';
export interface SessionInfo {
session_id: string;
access_token: string;
access_token_expires_at: number;
refresh_token: string;
refresh_token_expires_at: number;
}
@Injectable()
export class SessionService {
private getSessionKey(userId: string, sessionId: string): string {
return `session-${userId}-${sessionId}`;
}
async createSession(
userId: string,
accessToken: string,
refreshToken: string,
expirationConfig: {
accessTokenExpirationMs: number;
refreshTokenExpirationMs: number;
sessionTTL: number;
},
): Promise<SessionInfo> {
const sessionInfo: SessionInfo = {
session_id: uuidv4(),
access_token: accessToken,
access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs,
refresh_token: refreshToken,
refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs,
};
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
return sessionInfo;
}
async getSession(userId: string, sessionId: string): Promise<SessionInfo | null> {
const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
return sessionData ? JSON.parse(sessionData) : null;
}
async saveSession(
userId: string,
sessionInfo: SessionInfo,
ttl: number,
): Promise<void> {
await redis.setex(
this.getSessionKey(userId, sessionInfo.session_id),
ttl,
JSON.stringify(sessionInfo),
);
}
async deleteSession(userId: string, sessionId: string): Promise<void> {
await redis.del(this.getSessionKey(userId, sessionId));
}
}

View File

@ -0,0 +1,9 @@
export interface TokenConfig {
accessToken: {
expirationMs: number;
expirationTTL: number;
};
refreshToken: {
expirationMs: number;
};
}

View File

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

View File

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

View File

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

View File

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

15
apps/server/src/main.ts Normal file → Executable file
View File

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

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AppConfigService } from './app-config.service';
import { TrpcService } from '@server/trpc/trpc.service';
import { AppConfigRouter } from './app-config.router';
import { RealTimeModule } from '@server/socket/realtime/realtime.module';
@Module({
imports: [RealTimeModule],
providers: [AppConfigService, AppConfigRouter, TrpcService],
exports: [AppConfigService, AppConfigRouter]
})
export class AppConfigModule { }

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { AppConfigService } from './app-config.service';
import { z, ZodType } from 'zod';
import { Prisma } from '@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()
})
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,198 @@
import {
BadRequestException,
NotFoundException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
export const PrismaErrorCode = Object.freeze({
P2000: 'P2000',
P2001: 'P2001',
P2002: 'P2002',
P2003: 'P2003',
P2006: 'P2006',
P2007: 'P2007',
P2008: 'P2008',
P2009: 'P2009',
P2010: 'P2010',
P2011: 'P2011',
P2012: 'P2012',
P2014: 'P2014',
P2015: 'P2015',
P2016: 'P2016',
P2017: 'P2017',
P2018: 'P2018',
P2019: 'P2019',
P2021: 'P2021',
P2023: 'P2023',
P2025: 'P2025',
P2031: 'P2031',
P2033: 'P2033',
P2034: 'P2034',
P2037: 'P2037',
P1000: 'P1000',
P1001: 'P1001',
P1002: 'P1002',
P1015: 'P1015',
P1017: 'P1017',
});
export type PrismaErrorCode = keyof typeof PrismaErrorCode;
interface PrismaErrorMeta {
target?: string;
model?: string;
relationName?: string;
details?: string;
}
export type operationT = 'create' | 'read' | 'update' | 'delete';
export type PrismaErrorHandler = (
operation: operationT,
meta?: PrismaErrorMeta,
) => Error;
export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = {
P2000: (_operation, meta) => new BadRequestException(
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.`
),
P2001: (operation, meta) => new NotFoundException(
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`
),
P2002: (operation, meta) => {
const field = meta?.target || 'unique field';
switch (operation) {
case 'create':
return new ConflictException(
`A record with the same ${field} already exists. Please use a different value.`
);
case 'update':
return new ConflictException(
`The new value for ${field} conflicts with an existing record.`
);
default:
return new ConflictException(
`Unique constraint violation on ${field}.`
);
}
},
P2003: (operation) => new BadRequestException(
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`
),
P2006: (_operation, meta) => new BadRequestException(
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`
),
P2007: (operation) => new InternalServerErrorException(
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`
),
P2008: (operation) => new InternalServerErrorException(
`Failed to query the database during ${operation}. Please try again later.`
),
P2009: (operation) => new InternalServerErrorException(
`Invalid data fetched during ${operation}. Check query structure.`
),
P2010: () => new InternalServerErrorException(
`Invalid raw query. Ensure your query is correct and try again.`
),
P2011: (_operation, meta) => new BadRequestException(
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`
),
P2012: (operation, meta) => new BadRequestException(
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`
),
P2014: (operation) => {
switch (operation) {
case 'create':
return new BadRequestException(
`Cannot create record because the referenced data does not exist. Ensure related data exists.`
);
case 'delete':
return new BadRequestException(
`Unable to delete record because it is linked to other data. Update or delete dependent records first.`
);
default:
return new BadRequestException(`Foreign key constraint error.`);
}
},
P2015: () => new InternalServerErrorException(
`A record with the required ID was expected but not found. Please retry.`
),
P2016: (operation) => new InternalServerErrorException(
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`
),
P2017: (operation) => new InternalServerErrorException(
`Connected records were not found for ${operation}. Check related data.`
),
P2018: () => new InternalServerErrorException(
`The required connection could not be established. Please check relationships.`
),
P2019: (_operation, meta) => new InternalServerErrorException(
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`
),
P2021: (_operation, meta) => new InternalServerErrorException(
`The ${meta?.model || 'model'} was not found in the database.`
),
P2025: (operation, meta) => new NotFoundException(
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`
),
P2031: () => new InternalServerErrorException(
`Invalid Prisma Client initialization error. Please check configuration.`
),
P2033: (operation) => new InternalServerErrorException(
`Insufficient database write permissions for ${operation}.`
),
P2034: (operation) => new InternalServerErrorException(
`Database read-only transaction failed during ${operation}.`
),
P2037: (operation) => new InternalServerErrorException(
`Unsupported combinations of input types for ${operation}. Please correct the query or input.`
),
P1000: () => new InternalServerErrorException(
`Database authentication failed. Verify your credentials and try again.`
),
P1001: () => new InternalServerErrorException(
`The database server could not be reached. Please check its availability.`
),
P1002: () => new InternalServerErrorException(
`Connection to the database timed out. Verify network connectivity and server availability.`
),
P1015: (operation) => new InternalServerErrorException(
`Migration failed. Unable to complete ${operation}. Check migration history or database state.`
),
P1017: () => new InternalServerErrorException(
`Database connection failed. Ensure the database is online and credentials are correct.`
),
P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error {
throw new Error('Function not implemented.');
}
};

View File

@ -0,0 +1,183 @@
import { UserProfile, RowModelRequest, RowRequestSchema } from "@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)
}
}

View File

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

View File

@ -0,0 +1,138 @@
export interface FieldCondition {
field: string;
op: OperatorType
type?: "text" | "number" | "date";
value?: any;
valueTo?: any;
};
export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in';
export type LogicalCondition = FieldCondition | {
AND?: LogicalCondition[];
OR?: LogicalCondition[];
};
export function isFieldCondition(condition: LogicalCondition): condition is FieldCondition {
return (condition as FieldCondition).field !== undefined;
}
function buildCondition(condition: FieldCondition): string {
const { field, op, value, type = "text", valueTo } = condition;
switch (op) {
case 'equals':
return `${field} = '${value}'`;
case 'notEqual':
return `${field} != '${value}'`;
case 'contains':
return `${field} LIKE '%${value}%'`;
case 'startsWith':
return `${field} LIKE '${value}%'`;
case 'endsWith':
return `${field} LIKE '%${value}'`;
case 'blank':
if (type !== "date")
return `(${field} IS NULL OR ${field} = '')`;
else
return `${field} IS NULL`;
case 'notBlank':
if (type !== 'date')
return `${field} IS NOT NULL AND ${field} != ''`;
else
return `${field} IS NOT NULL`;
case 'greaterThan':
return `${field} > '${value}'`;
case 'lessThanOrEqual':
return `${field} <= '${value}'`;
case 'lessThan':
return `${field} < '${value}'`;
case 'greaterThan':
return `${field} > '${value}'`;
case 'inRange':
return `${field} >= '${value}' AND ${field} <= '${valueTo}'`;
case 'in':
if (!value || (Array.isArray(value) && value.length === 0)) {
// Return a condition that is always false if value is empty or an empty array
return '1 = 0';
}
return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`;
default:
return 'true'; // Default return for unmatched conditions
}
}
function buildLogicalCondition(logicalCondition: LogicalCondition): string {
if (isFieldCondition(logicalCondition)) {
return buildCondition(logicalCondition);
}
const parts: string[] = [];
if (logicalCondition.AND && logicalCondition.AND.length > 0) {
const andParts = logicalCondition.AND
.map(c => buildLogicalCondition(c))
.filter(part => part !== ''); // Filter out empty conditions
if (andParts.length > 0) {
parts.push(`(${andParts.join(' AND ')})`);
}
}
// Process OR conditions
if (logicalCondition.OR && logicalCondition.OR.length > 0) {
const orParts = logicalCondition.OR
.map(c => buildLogicalCondition(c))
.filter(part => part !== ''); // Filter out empty conditions
if (orParts.length > 0) {
parts.push(`(${orParts.join(' OR ')})`);
}
}
// Join AND and OR parts with an 'AND' if both are present
return parts.length > 1 ? parts.join(' AND ') : parts[0] || '';
}
export class SQLBuilder {
static select(fields: string[], distinctField?: string): string {
const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : "";
return `SELECT ${distinctClause}${fields.join(", ")}`;
}
static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string {
if (!orderBy) {
throw new Error("orderBy 参数不能为空");
}
let partitionClause = '';
if (partitionBy) {
partitionClause = `PARTITION BY ${partitionBy} `;
}
return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`;
}
static from(tableName: string): string {
return `FROM ${tableName}`;
}
static where(conditions: LogicalCondition): string {
const whereClause = buildLogicalCondition(conditions);
return whereClause ? `WHERE ${whereClause}` : "";
}
static groupBy(columns: string[]): string {
return columns.length ? `GROUP BY ${columns.join(", ")}` : "";
}
static orderBy(columns: string[]): string {
return columns.length ? `ORDER BY ${columns.join(", ")}` : "";
}
static limit(pageSize: number, offset: number = 0): string {
return `LIMIT ${pageSize + 1} OFFSET ${offset}`;
}
static join(clauses: string[]): string {
return clauses.filter(Boolean).join(' ');
}
static createFilterSql(key: string, item: any): LogicalCondition {
const conditionFuncs: Record<string, (item: { values?: any[], dateFrom?: string, dateTo?: string, filter: any, type: OperatorType, filterType: OperatorType }) => LogicalCondition> = {
text: (item) => ({ value: item.filter, op: item.type, field: key }),
number: (item) => ({ value: item.filter, op: item.type, field: key }),
date: (item) => ({ value: item.dateFrom, valueTo: item.dateTo, op: item.type, field: key }),
set: (item) => ({ value: item.values, op: "in", field: key })
}
return conditionFuncs[item.filterType](item)
}
}

View File

@ -0,0 +1,30 @@
SELECT *
FROM (
SELECT staff.id AS id,
ROW_NUMBER() OVER (
PARTITION BY staff.id
ORDER BY staff.id
) AS row_num,
staff.id AS id,
staff.username AS username,
staff.showname AS showname,
staff.avatar AS avatar,
staff.officer_id AS officer_id,
staff.phone_number AS phone_number,
staff.order AS order,
staff.enabled AS enabled,
dept.name AS dept_name,
domain.name AS domain_name
FROM staff
LEFT JOIN department dept ON staff.dept_id = dept.id
LEFT JOIN department domain ON staff.domain_id = domain.id
WHERE (
staff.deleted_at IS NULL
AND enabled = 'Thu Dec 26 2024 11:55:47 GMT+0800 (中国标准时间)'
AND staff.domain_id = '784c7583-c7f3-4179-873d-f8195ccf2acf'
AND staff.deleted_at IS NULL
)
ORDER BY "order" asc
)
WHERE row_num = '1'
LIMIT 31 OFFSET 0

View File

@ -0,0 +1,87 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { DepartmentService } from './department.service';
import { AuthGuard } from '@server/auth/auth.guard';
import { db } from '@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,
};
}
}
}

View File

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

View File

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

View File

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

View File

@ -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>} - IDnull
*/
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 - IDID的输入参数
* @returns
*
* :
* 1. ancestry和自身部门数据
* 2.
* 3.
* 4.
* 5.
*/
async getParentSimpleTree(
data: z.infer<typeof DepartmentMethodSchema.getSimpleTree>,
) {
// 解构输入参数
const { deptIds, domain, rootId } = data;
// 并行查询父级部门ancestry和自身部门数据
// 使用Promise.all提高查询效率,减少等待时间
const [parentData, selfData] = await Promise.all([
// 查询指定部门的所有祖先节点,包含子节点和父节点信息
db.deptAncestry.findMany({
where: {
descendantId: { in: deptIds }, // 查询条件:descendant在给定的部门ID列表中
ancestor: { isDomain: domain }, // 限定域
},
include: {
ancestor: {
include: {
children: true, // 包含子节点信息
parent: true, // 包含父节点信息
},
},
},
orderBy: { ancestor: { order: 'asc' } }, // 按祖先节点顺序升序排序
}),
// 查询自身部门数据
db.department.findMany({
where: { id: { in: deptIds }, isDomain: domain },
include: { children: true }, // 包含子节点信息
orderBy: { order: 'asc' }, // 按顺序升序排序
}),
]);
// 查询根节点的直接子节点
const rootChildren = await db.deptAncestry.findMany({
where: {
ancestorId: rootId, // 祖先ID为根ID
descendant: { isDomain: domain }, // 限定域
},
});
/**
*
*
* @param ancestor -
* @returns
*/
const isDirectDescendantOfRoot = (ancestor: DeptAncestry): boolean => {
return (
rootChildren.findIndex(
(child) => child.descendantId === ancestor.ancestorId,
) !== -1
);
};
// 处理父级节点:过滤并映射为简单树结构
const parents = parentData
.map(({ ancestor }) => ancestor) // 提取祖先节点
.filter(
(ancestor) => ancestor && isDirectDescendantOfRoot(ancestor as any),
) // 过滤有效且超出根节点层级的节点
.map(mapToDeptSimpleTree); // 映射为简单树结构
// 处理自身节点:映射为简单树结构
const selfItems = selfData.map(mapToDeptSimpleTree);
// 合并并去重父级和自身节点,返回唯一项
return getUniqueItems([...parents, ...selfItems], 'id');
}
}

View File

@ -0,0 +1,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列表
* :
* - 使findManyID列表查询相关部门的员工信息
* - 使flatMap将查询结果扁平化ID
*/
export async function getStaffsByDeptIds(ids: string[]) {
const depts = await db.department.findMany({
where: { id: { in: ids } },
select: {
deptStaffs: {
select: { id: true }
}
},
});
return depts.flatMap((dept) => dept.deptStaffs);
}
/**
* ID列表
* @param params ID列表ID列表和员工信息
* @returns ID列表
* :
* - ID列表获取相关员工ID
* - ID与传入的员工ID列表合并使Set去重
* - ID
* - ID列表
*/
export async function extractUniqueStaffIds(params: { deptIds?: string[], staffIds?: string[], staff?: UserProfile }): Promise<string[]> {
const { deptIds, staff, staffIds } = params;
const deptStaffs = await getStaffsByDeptIds(deptIds);
const result = new Set(deptStaffs.map(item => item.id).concat(staffIds));
if (staff) {
result.delete(staff.id);
}
return Array.from(result);
}

View File

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

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { MessageService } from './message.service';
import { MessageRouter } from './message.router';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentModule } from '../department/department.module';
import { MessageController } from './message.controller';
@Module({
imports: [DepartmentModule],
providers: [MessageService, MessageRouter, TrpcService],
exports: [MessageService, MessageRouter],
controllers: [MessageController],
})
export class MessageModule { }

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { MessageService } from './message.service';
import { 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);
})
})
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentService } from '@server/models/department/department.service';
import { QueueModule } from '@server/queue/queue.module';
import { MessageModule } from '../message/message.module';
import { PostRouter } from './post.router';
import { PostController } from './post.controller';
import { PostService } from './post.service';
import { RoleMapModule } from '../rbac/rbac.module';
@Module({
imports: [QueueModule, RoleMapModule, MessageModule],
providers: [PostService, PostRouter, TrpcService, DepartmentService],
exports: [PostRouter, PostService],
controllers: [PostController],
})
export class PostModule {}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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 IDID和对象ID的数据
* @returns
*/
async getPermsForObject(
data: z.infer<typeof RoleMapMethodSchema.getPermsForObject>,
) {
const { domainId, deptId, staffId } = data;
// Get all ancestor department IDs if deptId is provided.
const ancestorDeptIds = deptId
? await this.departmentService.getAncestorIds(deptId)
: [];
// Define a common filter for querying roles.
const objectFilters: Prisma.RoleMapWhereInput[] = [
{ objectId: staffId, objectType: ObjectType.STAFF },
...(deptId || ancestorDeptIds.length > 0
? [
{
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
objectType: ObjectType.DEPARTMENT,
},
]
: []),
];
// Helper function to fetch roles based on domain ID.
const fetchRoles = async (domainId: string) => {
return db.roleMap.findMany({
where: {
AND: {
domainId,
OR: objectFilters,
},
},
include: { role: true },
});
};
// Fetch roles with and without specific domain IDs.
const [nullDomainRoles, userRoles] = await Promise.all([
fetchRoles(null),
fetchRoles(domainId),
]);
// Extract permissions from roles and return them.
return [...userRoles, ...nullDomainRoles].flatMap(
({ role }) => role.permissions,
);
}
/**
*
* @param data ID列表的数据
* @returns
* @throws ID
*/
async deleteMany(data: z.infer<typeof RoleMapMethodSchema.deleteMany>) {
const { ids } = data;
if (!ids || ids.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No IDs provided for deletion.',
});
}
const rowData = await this.getRowByIds({ ids, extraCondition: {} });
await db.roleMap.deleteMany({
where: { id: { in: ids } },
});
return rowData;
}
/**
*
* @param data
* @returns
*/
async paginate(data: z.infer<typeof RoleMapMethodSchema.paginate>) {
const { page, pageSize, domainId, roleId } = data;
const [items, totalCount] = await Promise.all([
db.roleMap.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
where: { domainId, roleId },
}),
db.roleMap.count({
where: { domainId, roleId },
}),
]);
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
return { items, totalCount };
}
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) {
const { domainId, roleId } = data;
let staffs = await db.staff.findMany({
where: {
domainId,
},
});
const roleMaps = await db.roleMap.findMany({
where: {
domainId,
roleId,
objectType: ObjectType.STAFF,
},
});
staffs = staffs.filter(
(staff) =>
roleMaps.findIndex((roleMap) => roleMap.objectId === staff.id) === -1,
);
return staffs;
}
/**
*
* @param data
* @returns
*/
async update(data: z.infer<typeof RoleMapMethodSchema.update>) {
const { id, ...others } = data;
const updatedRoleMap = await db.roleMap.update({
where: { id },
data: { ...others },
});
return updatedRoleMap;
}
/**
*
* @param data ID和域ID的数据
* @returns ID和员工ID列表
*/
async getRoleMapDetail(data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>) {
const { roleId, domainId } = data;
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
const deptIds = res
.filter((item) => item.objectType === ObjectType.DEPARTMENT)
.map((item) => item.objectId);
const staffIds = res
.filter((item) => item.objectType === ObjectType.STAFF)
.map((item) => item.objectId);
return { deptIds, staffIds };
}
}

View File

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

12
apps/server/src/models/staff/staff.module.ts Normal file → Executable file
View File

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

View File

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

View File

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

View File

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

336
apps/server/src/models/staff/staff.service.ts Normal file → Executable file
View File

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

View File

@ -0,0 +1,28 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { TaxonomyService } from './taxonomy.service';
import { AuthGuard } from '@server/auth/auth.guard';
import { db } from '@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,
};
}
}
}

10
apps/server/src/models/taxonomy/taxonomy.module.ts Normal file → Executable file
View File

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

81
apps/server/src/models/taxonomy/taxonomy.router.ts Normal file → Executable file
View File

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

View File

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

View File

@ -0,0 +1,28 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { TermService } from './term.service';
import { AuthGuard } from '@server/auth/auth.guard';
import { db } from '@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,
};
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VisitService } from './visit.service';
import { VisitRouter } from './visit.router';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
providers: [VisitService, VisitRouter, TrpcService],
exports: [VisitRouter]
})
export class VisitModule { }

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { 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);
}),
});
}

View File

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

View File

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

View File

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

View File

@ -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, // 设置请求超时时间
});
}
}

View File

@ -0,0 +1,4 @@
export type CustomJobType = "pushMessage" | "updateTroubleViewCount"
export type updateViewCountJobData = {
id: string
}

View File

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

View File

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

39
apps/server/src/queue/queue.module.ts Normal file → Executable file
View File

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

15
apps/server/src/queue/worker/processor.ts Normal file → Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,205 @@
import { WebSocketServer, WebSocket } from "ws";
import { Logger } from "@nestjs/common";
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
import { SocketMessage } from '@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));
}
}

View File

@ -0,0 +1,150 @@
/**
*
* (ArrayMapTextXML等)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 {};
}
};

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { YjsServer } from './yjs.server';
@Module({
providers: [YjsServer],
exports: [YjsServer]
})
export class CollaborationModule { }

View File

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

View File

@ -0,0 +1,5 @@
export interface ConnectionOptions {
docName: string;
gc: boolean;
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RealtimeServer } from './realtime.server';
@Module({
providers: [ RealtimeServer],
exports: [ RealtimeServer]
})
export class RealTimeModule { }

View File

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

View File

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

View File

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