add
This commit is contained in:
parent
f3cc347a8e
commit
58fc9d8895
|
@ -23,12 +23,12 @@ import { UploadModule } from './upload/upload.module';
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true, // 全局可用
|
isGlobal: true, // 全局可用
|
||||||
envFilePath: '.env'
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
global: true,
|
global: true,
|
||||||
secret: env.JWT_SECRET
|
secret: env.JWT_SECRET,
|
||||||
}),
|
}),
|
||||||
WebSocketModule,
|
WebSocketModule,
|
||||||
TrpcModule,
|
TrpcModule,
|
||||||
|
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
RealTimeModule,
|
RealTimeModule,
|
||||||
UploadModule
|
UploadModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_FILTER,
|
||||||
|
useClass: ExceptionsFilter,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
providers: [{
|
|
||||||
provide: APP_FILTER,
|
|
||||||
useClass: ExceptionsFilter,
|
|
||||||
}],
|
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule {}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { env } from '@server/env';
|
import { env } from '@server/env';
|
||||||
|
@ -12,26 +12,21 @@ import { extractTokenFromHeader } from './utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
constructor(private jwtService: JwtService) { }
|
constructor(private jwtService: JwtService) {}
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const token = extractTokenFromHeader(request);
|
const token = extractTokenFromHeader(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
|
||||||
try {
|
|
||||||
const payload: JwtPayload = await this.jwtService.verifyAsync(
|
|
||||||
token,
|
|
||||||
{
|
|
||||||
secret: env.JWT_SECRET
|
|
||||||
}
|
|
||||||
);
|
|
||||||
request['user'] = payload;
|
|
||||||
} catch {
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
|
||||||
}
|
secret: env.JWT_SECRET,
|
||||||
|
});
|
||||||
|
request['user'] = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,12 +8,8 @@ import { SessionService } from './session.service';
|
||||||
import { RoleMapModule } from '@server/models/rbac/rbac.module';
|
import { RoleMapModule } from '@server/models/rbac/rbac.module';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StaffModule, RoleMapModule],
|
imports: [StaffModule, RoleMapModule],
|
||||||
providers: [
|
providers: [AuthService, TrpcService, DepartmentService, SessionService],
|
||||||
AuthService,
|
|
||||||
TrpcService,
|
|
||||||
DepartmentService,
|
|
||||||
SessionService],
|
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule { }
|
export class AuthModule {}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export const tokenConfig = {
|
export const tokenConfig = {
|
||||||
accessToken: {
|
accessToken: {
|
||||||
expirationMs: 7 * 24 * 3600000, // 7 days
|
expirationMs: 7 * 24 * 3600000, // 7 days
|
||||||
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
expirationTTL: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||||
},
|
},
|
||||||
refreshToken: {
|
refreshToken: {
|
||||||
expirationMs: 30 * 24 * 3600000, // 30 days
|
expirationMs: 30 * 24 * 3600000, // 30 days
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,58 +4,63 @@ import { redis } from '@server/utils/redis/redis.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
access_token: string;
|
access_token: string;
|
||||||
access_token_expires_at: number;
|
access_token_expires_at: number;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
refresh_token_expires_at: number;
|
refresh_token_expires_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
private getSessionKey(userId: string, sessionId: string): string {
|
private getSessionKey(userId: string, sessionId: string): string {
|
||||||
return `session-${userId}-${sessionId}`;
|
return `session-${userId}-${sessionId}`;
|
||||||
}
|
}
|
||||||
async createSession(
|
async createSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
expirationConfig: {
|
expirationConfig: {
|
||||||
accessTokenExpirationMs: number;
|
accessTokenExpirationMs: number;
|
||||||
refreshTokenExpirationMs: number;
|
refreshTokenExpirationMs: number;
|
||||||
sessionTTL: number;
|
sessionTTL: number;
|
||||||
},
|
},
|
||||||
): Promise<SessionInfo> {
|
): Promise<SessionInfo> {
|
||||||
const sessionInfo: SessionInfo = {
|
const sessionInfo: SessionInfo = {
|
||||||
session_id: uuidv4(),
|
session_id: uuidv4(),
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs,
|
access_token_expires_at:
|
||||||
refresh_token: refreshToken,
|
Date.now() + expirationConfig.accessTokenExpirationMs,
|
||||||
refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs,
|
refresh_token: refreshToken,
|
||||||
};
|
refresh_token_expires_at:
|
||||||
|
Date.now() + expirationConfig.refreshTokenExpirationMs,
|
||||||
|
};
|
||||||
|
|
||||||
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
|
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
|
||||||
return sessionInfo;
|
return sessionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSession(userId: string, sessionId: string): Promise<SessionInfo | null> {
|
async getSession(
|
||||||
const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
|
userId: string,
|
||||||
return sessionData ? JSON.parse(sessionData) : null;
|
sessionId: string,
|
||||||
}
|
): Promise<SessionInfo | null> {
|
||||||
|
const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
|
||||||
|
return sessionData ? JSON.parse(sessionData) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async saveSession(
|
async saveSession(
|
||||||
userId: string,
|
userId: string,
|
||||||
sessionInfo: SessionInfo,
|
sessionInfo: SessionInfo,
|
||||||
ttl: number,
|
ttl: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await redis.setex(
|
await redis.setex(
|
||||||
this.getSessionKey(userId, sessionInfo.session_id),
|
this.getSessionKey(userId, sessionInfo.session_id),
|
||||||
ttl,
|
ttl,
|
||||||
JSON.stringify(sessionInfo),
|
JSON.stringify(sessionInfo),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSession(userId: string, sessionId: string): Promise<void> {
|
async deleteSession(userId: string, sessionId: string): Promise<void> {
|
||||||
await redis.del(this.getSessionKey(userId, sessionId));
|
await redis.del(this.getSessionKey(userId, sessionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
export interface TokenConfig {
|
export interface TokenConfig {
|
||||||
accessToken: {
|
accessToken: {
|
||||||
expirationMs: number;
|
expirationMs: number;
|
||||||
expirationTTL: number;
|
expirationTTL: number;
|
||||||
};
|
};
|
||||||
refreshToken: {
|
refreshToken: {
|
||||||
expirationMs: number;
|
expirationMs: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileAuthResult {
|
export interface FileAuthResult {
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
userId?: string
|
userId?: string;
|
||||||
resourceType?: string
|
resourceType?: string;
|
||||||
error?: string
|
error?: string;
|
||||||
}
|
}
|
||||||
export interface FileRequest {
|
export interface FileRequest {
|
||||||
originalUri: string;
|
originalUri: string;
|
||||||
realIp: string;
|
realIp: string;
|
||||||
method: string;
|
method: string;
|
||||||
queryParams: string;
|
queryParams: string;
|
||||||
host: string;
|
host: string;
|
||||||
authorization: string
|
authorization: string;
|
||||||
}
|
}
|
||||||
export enum FileValidationErrorType {
|
export enum FileValidationErrorType {
|
||||||
INVALID_URI = 'INVALID_URI',
|
INVALID_URI = 'INVALID_URI',
|
||||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
||||||
AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED',
|
AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED',
|
||||||
INVALID_TOKEN = 'INVALID_TOKEN',
|
INVALID_TOKEN = 'INVALID_TOKEN',
|
||||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
|
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { env } from '@server/env';
|
||||||
import { redis } from '@server/utils/redis/redis.service';
|
import { redis } from '@server/utils/redis/redis.service';
|
||||||
import EventBus from '@server/utils/event-bus';
|
import EventBus from '@server/utils/event-bus';
|
||||||
import { RoleMapService } from '@server/models/rbac/rolemap.service';
|
import { RoleMapService } from '@server/models/rbac/rolemap.service';
|
||||||
import { Request } from "express"
|
import { Request } from 'express';
|
||||||
interface ProfileResult {
|
interface ProfileResult {
|
||||||
staff: UserProfile | undefined;
|
staff: UserProfile | undefined;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
@ -22,9 +22,11 @@ interface TokenVerifyResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
export function extractTokenFromHeader(request: Request): string | undefined {
|
export function extractTokenFromHeader(request: Request): string | undefined {
|
||||||
return extractTokenFromAuthorization(request.headers.authorization)
|
return extractTokenFromAuthorization(request.headers.authorization);
|
||||||
}
|
}
|
||||||
export function extractTokenFromAuthorization(authorization: string): string | undefined {
|
export function extractTokenFromAuthorization(
|
||||||
|
authorization: string,
|
||||||
|
): string | undefined {
|
||||||
const [type, token] = authorization?.split(' ') ?? [];
|
const [type, token] = authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
}
|
}
|
||||||
|
@ -40,7 +42,7 @@ export class UserProfileService {
|
||||||
this.jwtService = new JwtService();
|
this.jwtService = new JwtService();
|
||||||
this.departmentService = new DepartmentService();
|
this.departmentService = new DepartmentService();
|
||||||
this.roleMapService = new RoleMapService(this.departmentService);
|
this.roleMapService = new RoleMapService(this.departmentService);
|
||||||
EventBus.on("dataChanged", ({ type, data }) => {
|
EventBus.on('dataChanged', ({ type, data }) => {
|
||||||
if (type === ObjectType.STAFF) {
|
if (type === ObjectType.STAFF) {
|
||||||
// 确保 data 是数组,如果不是则转换为数组
|
// 确保 data 是数组,如果不是则转换为数组
|
||||||
const dataArray = Array.isArray(data) ? data : [data];
|
const dataArray = Array.isArray(data) ? data : [data];
|
||||||
|
@ -51,7 +53,6 @@ export class UserProfileService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
public getProfileCacheKey(id: string) {
|
public getProfileCacheKey(id: string) {
|
||||||
return `user-profile-${id}`;
|
return `user-profile-${id}`;
|
||||||
|
@ -175,9 +176,7 @@ export class UserProfileService {
|
||||||
staff.deptId
|
staff.deptId
|
||||||
? this.departmentService.getDescendantIdsInDomain(staff.deptId)
|
? this.departmentService.getDescendantIdsInDomain(staff.deptId)
|
||||||
: [],
|
: [],
|
||||||
staff.deptId
|
staff.deptId ? this.departmentService.getAncestorIds([staff.deptId]) : [],
|
||||||
? this.departmentService.getAncestorIds([staff.deptId])
|
|
||||||
: [],
|
|
||||||
this.roleMapService.getPermsForObject({
|
this.roleMapService.getPermsForObject({
|
||||||
domainId: staff.domainId,
|
domainId: staff.domainId,
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
|
|
|
@ -7,6 +7,6 @@ import { RealTimeModule } from '@server/socket/realtime/realtime.module';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RealTimeModule],
|
imports: [RealTimeModule],
|
||||||
providers: [AppConfigService, AppConfigRouter, TrpcService],
|
providers: [AppConfigService, AppConfigRouter, TrpcService],
|
||||||
exports: [AppConfigService, AppConfigRouter]
|
exports: [AppConfigService, AppConfigRouter],
|
||||||
})
|
})
|
||||||
export class AppConfigModule { }
|
export class AppConfigModule {}
|
||||||
|
|
|
@ -1,198 +1,222 @@
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
InternalServerErrorException,
|
InternalServerErrorException,
|
||||||
} from '@nestjs/common';
|
} 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(
|
export const PrismaErrorCode = Object.freeze({
|
||||||
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
P2002: (operation, meta) => {
|
export type PrismaErrorCode = keyof typeof PrismaErrorCode;
|
||||||
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(
|
interface PrismaErrorMeta {
|
||||||
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`
|
target?: string;
|
||||||
),
|
model?: string;
|
||||||
|
relationName?: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
P2006: (_operation, meta) => new BadRequestException(
|
export type operationT = 'create' | 'read' | 'update' | 'delete';
|
||||||
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`
|
|
||||||
),
|
|
||||||
|
|
||||||
P2007: (operation) => new InternalServerErrorException(
|
export type PrismaErrorHandler = (
|
||||||
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`
|
operation: operationT,
|
||||||
),
|
meta?: PrismaErrorMeta,
|
||||||
|
) => Error;
|
||||||
|
|
||||||
P2008: (operation) => new InternalServerErrorException(
|
export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = {
|
||||||
`Failed to query the database during ${operation}. Please try again later.`
|
P2000: (_operation, meta) =>
|
||||||
),
|
new BadRequestException(
|
||||||
|
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2009: (operation) => new InternalServerErrorException(
|
P2001: (operation, meta) =>
|
||||||
`Invalid data fetched during ${operation}. Check query structure.`
|
new NotFoundException(
|
||||||
),
|
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2010: () => new InternalServerErrorException(
|
P2002: (operation, meta) => {
|
||||||
`Invalid raw query. Ensure your query is correct and try again.`
|
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}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
P2011: (_operation, meta) => new BadRequestException(
|
P2003: (operation) =>
|
||||||
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`
|
new BadRequestException(
|
||||||
),
|
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2012: (operation, meta) => new BadRequestException(
|
P2006: (_operation, meta) =>
|
||||||
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`
|
new BadRequestException(
|
||||||
),
|
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2014: (operation) => {
|
P2007: (operation) =>
|
||||||
switch (operation) {
|
new InternalServerErrorException(
|
||||||
case 'create':
|
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`,
|
||||||
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(
|
P2008: (operation) =>
|
||||||
`A record with the required ID was expected but not found. Please retry.`
|
new InternalServerErrorException(
|
||||||
),
|
`Failed to query the database during ${operation}. Please try again later.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2016: (operation) => new InternalServerErrorException(
|
P2009: (operation) =>
|
||||||
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`
|
new InternalServerErrorException(
|
||||||
),
|
`Invalid data fetched during ${operation}. Check query structure.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2017: (operation) => new InternalServerErrorException(
|
P2010: () =>
|
||||||
`Connected records were not found for ${operation}. Check related data.`
|
new InternalServerErrorException(
|
||||||
),
|
`Invalid raw query. Ensure your query is correct and try again.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2018: () => new InternalServerErrorException(
|
P2011: (_operation, meta) =>
|
||||||
`The required connection could not be established. Please check relationships.`
|
new BadRequestException(
|
||||||
),
|
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2019: (_operation, meta) => new InternalServerErrorException(
|
P2012: (operation, meta) =>
|
||||||
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`
|
new BadRequestException(
|
||||||
),
|
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2021: (_operation, meta) => new InternalServerErrorException(
|
P2014: (operation) => {
|
||||||
`The ${meta?.model || 'model'} was not found in the database.`
|
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.`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
P2025: (operation, meta) => new NotFoundException(
|
P2015: () =>
|
||||||
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`
|
new InternalServerErrorException(
|
||||||
),
|
`A record with the required ID was expected but not found. Please retry.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2031: () => new InternalServerErrorException(
|
P2016: (operation) =>
|
||||||
`Invalid Prisma Client initialization error. Please check configuration.`
|
new InternalServerErrorException(
|
||||||
),
|
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2033: (operation) => new InternalServerErrorException(
|
P2017: (operation) =>
|
||||||
`Insufficient database write permissions for ${operation}.`
|
new InternalServerErrorException(
|
||||||
),
|
`Connected records were not found for ${operation}. Check related data.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2034: (operation) => new InternalServerErrorException(
|
P2018: () =>
|
||||||
`Database read-only transaction failed during ${operation}.`
|
new InternalServerErrorException(
|
||||||
),
|
`The required connection could not be established. Please check relationships.`,
|
||||||
|
),
|
||||||
|
|
||||||
P2037: (operation) => new InternalServerErrorException(
|
P2019: (_operation, meta) =>
|
||||||
`Unsupported combinations of input types for ${operation}. Please correct the query or input.`
|
new InternalServerErrorException(
|
||||||
),
|
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`,
|
||||||
|
),
|
||||||
|
|
||||||
P1000: () => new InternalServerErrorException(
|
P2021: (_operation, meta) =>
|
||||||
`Database authentication failed. Verify your credentials and try again.`
|
new InternalServerErrorException(
|
||||||
),
|
`The ${meta?.model || 'model'} was not found in the database.`,
|
||||||
|
),
|
||||||
|
|
||||||
P1001: () => new InternalServerErrorException(
|
P2025: (operation, meta) =>
|
||||||
`The database server could not be reached. Please check its availability.`
|
new NotFoundException(
|
||||||
),
|
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`,
|
||||||
|
),
|
||||||
|
|
||||||
P1002: () => new InternalServerErrorException(
|
P2031: () =>
|
||||||
`Connection to the database timed out. Verify network connectivity and server availability.`
|
new InternalServerErrorException(
|
||||||
),
|
`Invalid Prisma Client initialization error. Please check configuration.`,
|
||||||
|
),
|
||||||
|
|
||||||
P1015: (operation) => new InternalServerErrorException(
|
P2033: (operation) =>
|
||||||
`Migration failed. Unable to complete ${operation}. Check migration history or database state.`
|
new InternalServerErrorException(
|
||||||
),
|
`Insufficient database write permissions for ${operation}.`,
|
||||||
|
),
|
||||||
|
|
||||||
P1017: () => new InternalServerErrorException(
|
P2034: (operation) =>
|
||||||
`Database connection failed. Ensure the database is online and credentials are correct.`
|
new InternalServerErrorException(
|
||||||
),
|
`Database read-only transaction failed during ${operation}.`,
|
||||||
P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error {
|
),
|
||||||
throw new Error('Function not implemented.');
|
|
||||||
}
|
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.');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -1,183 +1,176 @@
|
||||||
import { UserProfile, RowModelRequest, RowRequestSchema } from "@nice/common";
|
import { UserProfile, RowModelRequest, RowRequestSchema } from '@nice/common';
|
||||||
import { RowModelService } from "./row-model.service";
|
import { RowModelService } from './row-model.service';
|
||||||
import { isFieldCondition, LogicalCondition, SQLBuilder } from "./sql-builder";
|
import { isFieldCondition, LogicalCondition, SQLBuilder } from './sql-builder';
|
||||||
import EventBus from "@server/utils/event-bus";
|
import EventBus from '@server/utils/event-bus';
|
||||||
import supejson from "superjson-cjs"
|
import supejson from 'superjson-cjs';
|
||||||
import { deleteByPattern } from "@server/utils/redis/utils";
|
import { deleteByPattern } from '@server/utils/redis/utils';
|
||||||
import { redis } from "@server/utils/redis/redis.service";
|
import { redis } from '@server/utils/redis/redis.service';
|
||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
export class RowCacheService extends RowModelService {
|
export class RowCacheService extends RowModelService {
|
||||||
constructor(tableName: string, private enableCache: boolean = true) {
|
constructor(
|
||||||
super(tableName)
|
tableName: string,
|
||||||
if (this.enableCache) {
|
private enableCache: boolean = true,
|
||||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
) {
|
||||||
if (type === tableName) {
|
super(tableName);
|
||||||
const dataArray = Array.isArray(data) ? data : [data];
|
if (this.enableCache) {
|
||||||
for (const item of dataArray) {
|
EventBus.on('dataChanged', async ({ type, data }) => {
|
||||||
try {
|
if (type === tableName) {
|
||||||
if (item.id) {
|
const dataArray = Array.isArray(data) ? data : [data];
|
||||||
this.invalidateRowCacheById(item.id)
|
for (const item of dataArray) {
|
||||||
}
|
try {
|
||||||
if (item.parentId) {
|
if (item.id) {
|
||||||
this.invalidateRowCacheById(item.parentId)
|
this.invalidateRowCacheById(item.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
if (item.parentId) {
|
||||||
console.error(`Error deleting cache for type ${tableName}:`, err);
|
this.invalidateRowCacheById(item.parentId);
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
}
|
console.error(`Error deleting cache for type ${tableName}:`, err);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
protected getRowCacheKey(id: string) {
|
}
|
||||||
return `row-data-${id}`;
|
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);
|
||||||
}
|
}
|
||||||
private async invalidateRowCacheById(id: string) {
|
const key = this.getRowCacheKey(data.id);
|
||||||
if (!this.enableCache) return;
|
try {
|
||||||
const pattern = this.getRowCacheKey(id);
|
// 尝试从缓存获取数据
|
||||||
await deleteByPattern(pattern);
|
const cachedData = await this.getCachedData(key, staff);
|
||||||
}
|
// 如果缓存命中,直接返回
|
||||||
createJoinSql(request?: RowModelRequest): string[] {
|
if (cachedData) return cachedData;
|
||||||
return []
|
// 处理数据并缓存
|
||||||
}
|
const processedData = await this.processDataWithPermissions(data, staff);
|
||||||
protected async getRowRelation(args: { data: any, staff?: UserProfile }) {
|
await redis.set(key, supejson.stringify(processedData));
|
||||||
return args.data;
|
return processedData;
|
||||||
}
|
} catch (err) {
|
||||||
protected async setResPermissions(
|
this.logger.error('Error in getRowDto:', err);
|
||||||
data: any,
|
throw err;
|
||||||
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(
|
private async getCachedData(
|
||||||
key: string,
|
key: string,
|
||||||
staff?: UserProfile
|
staff?: UserProfile,
|
||||||
): Promise<any | null> {
|
): Promise<any | null> {
|
||||||
const cachedDataStr = await redis.get(key);
|
const cachedDataStr = await redis.get(key);
|
||||||
if (!cachedDataStr) return null;
|
if (!cachedDataStr) return null;
|
||||||
const cachedData = supejson.parse(cachedDataStr) as any;
|
const cachedData = supejson.parse(cachedDataStr) as any;
|
||||||
if (!cachedData?.id) return null;
|
if (!cachedData?.id) return null;
|
||||||
return staff
|
return staff ? this.setResPermissions(cachedData, staff) : cachedData;
|
||||||
? this.setResPermissions(cachedData, staff)
|
}
|
||||||
: cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processDataWithPermissions(
|
private async processDataWithPermissions(
|
||||||
data: any,
|
data: any,
|
||||||
staff?: UserProfile
|
staff?: UserProfile,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// 处理权限
|
// 处理权限
|
||||||
const permData = staff
|
const permData = staff ? await this.setResPermissions(data, staff) : data;
|
||||||
? await this.setResPermissions(data, staff)
|
// 获取关联数据
|
||||||
: data;
|
return this.getRowRelation({ data: permData, staff });
|
||||||
// 获取关联数据
|
}
|
||||||
return this.getRowRelation({ data: permData, staff });
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createGetRowsFilters(
|
protected createGetRowsFilters(
|
||||||
request: z.infer<typeof RowRequestSchema>,
|
request: z.infer<typeof RowRequestSchema>,
|
||||||
staff?: UserProfile,
|
staff?: UserProfile,
|
||||||
) {
|
) {
|
||||||
const condition = super.createGetRowsFilters(request);
|
const condition = super.createGetRowsFilters(request);
|
||||||
if (isFieldCondition(condition)) return {};
|
if (isFieldCondition(condition)) return {};
|
||||||
const baseCondition: LogicalCondition[] = [
|
const baseCondition: LogicalCondition[] = [
|
||||||
{
|
{
|
||||||
field: `${this.tableName}.deleted_at`,
|
field: `${this.tableName}.deleted_at`,
|
||||||
op: 'blank',
|
op: 'blank',
|
||||||
type: 'date',
|
type: 'date',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
condition.AND = [...baseCondition, ...condition.AND];
|
condition.AND = [...baseCondition, ...condition.AND];
|
||||||
return condition;
|
return condition;
|
||||||
}
|
}
|
||||||
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||||
return [
|
return [
|
||||||
`${this.tableName}.id AS id`,
|
`${this.tableName}.id AS id`,
|
||||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
protected createGroupingRowSelect(
|
protected createGroupingRowSelect(
|
||||||
request: RowModelRequest,
|
request: RowModelRequest,
|
||||||
wrapperSql: boolean,
|
wrapperSql: boolean,
|
||||||
): string[] {
|
): string[] {
|
||||||
const colsToSelect = super.createGroupingRowSelect(request, wrapperSql);
|
const colsToSelect = super.createGroupingRowSelect(request, wrapperSql);
|
||||||
return colsToSelect.concat([
|
return colsToSelect.concat([
|
||||||
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
SQLBuilder.rowNumber(`${this.tableName}.id`, `${this.tableName}.id`),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
protected async getRowsSqlWrapper(
|
protected async getRowsSqlWrapper(
|
||||||
sql: string,
|
sql: string,
|
||||||
request?: RowModelRequest,
|
request?: RowModelRequest,
|
||||||
staff?: UserProfile,
|
staff?: UserProfile,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const groupingSql = SQLBuilder.join([
|
const groupingSql = SQLBuilder.join([
|
||||||
SQLBuilder.select([
|
SQLBuilder.select([
|
||||||
...this.createGroupingRowSelect(request, true),
|
...this.createGroupingRowSelect(request, true),
|
||||||
`${this.tableName}.id AS id`,
|
`${this.tableName}.id AS id`,
|
||||||
]),
|
]),
|
||||||
SQLBuilder.from(this.tableName),
|
SQLBuilder.from(this.tableName),
|
||||||
SQLBuilder.join(this.createJoinSql(request)),
|
SQLBuilder.join(this.createJoinSql(request)),
|
||||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||||
]);
|
]);
|
||||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||||
if (this.isDoingGroup(request)) {
|
if (this.isDoingGroup(request)) {
|
||||||
const rowGroupCol = rowGroupCols[groupKeys.length];
|
const rowGroupCol = rowGroupCols[groupKeys.length];
|
||||||
const groupByField = rowGroupCol?.field?.replace('.', '_');
|
const groupByField = rowGroupCol?.field?.replace('.', '_');
|
||||||
return SQLBuilder.join([
|
return SQLBuilder.join([
|
||||||
SQLBuilder.select([
|
SQLBuilder.select([
|
||||||
groupByField,
|
groupByField,
|
||||||
...super.createAggSqlForWrapper(request),
|
...super.createAggSqlForWrapper(request),
|
||||||
'COUNT(id) AS child_count',
|
'COUNT(id) AS child_count',
|
||||||
]),
|
]),
|
||||||
SQLBuilder.from(`(${groupingSql})`),
|
SQLBuilder.from(`(${groupingSql})`),
|
||||||
SQLBuilder.where({
|
SQLBuilder.where({
|
||||||
field: 'row_num',
|
field: 'row_num',
|
||||||
value: '1',
|
value: '1',
|
||||||
op: 'equals',
|
op: 'equals',
|
||||||
}),
|
}),
|
||||||
SQLBuilder.groupBy([groupByField]),
|
SQLBuilder.groupBy([groupByField]),
|
||||||
SQLBuilder.orderBy(
|
SQLBuilder.orderBy(
|
||||||
this.getOrderByColumns(request).map((item) => item.replace('.', '_')),
|
this.getOrderByColumns(request).map((item) => item.replace('.', '_')),
|
||||||
),
|
),
|
||||||
this.getLimitSql(request),
|
this.getLimitSql(request),
|
||||||
]);
|
]);
|
||||||
} else
|
} else
|
||||||
return SQLBuilder.join([
|
return SQLBuilder.join([
|
||||||
SQLBuilder.select(['*']),
|
SQLBuilder.select(['*']),
|
||||||
SQLBuilder.from(`(${sql})`),
|
SQLBuilder.from(`(${sql})`),
|
||||||
SQLBuilder.where({
|
SQLBuilder.where({
|
||||||
field: 'row_num',
|
field: 'row_num',
|
||||||
value: '1',
|
value: '1',
|
||||||
op: 'equals',
|
op: 'equals',
|
||||||
}),
|
}),
|
||||||
this.getLimitSql(request),
|
this.getLimitSql(request),
|
||||||
]);
|
]);
|
||||||
// return super.getRowsSqlWrapper(sql, request)
|
// return super.getRowsSqlWrapper(sql, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ export abstract class RowModelService {
|
||||||
// 添加更多需要引号的关键词
|
// 添加更多需要引号的关键词
|
||||||
]);
|
]);
|
||||||
protected logger = new Logger(this.tableName);
|
protected logger = new Logger(this.tableName);
|
||||||
protected constructor(protected tableName: string) { }
|
protected constructor(protected tableName: string) {}
|
||||||
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
@ -140,11 +140,11 @@ export abstract class RowModelService {
|
||||||
private buildFilterConditions(filterModel: any): LogicalCondition[] {
|
private buildFilterConditions(filterModel: any): LogicalCondition[] {
|
||||||
return filterModel
|
return filterModel
|
||||||
? Object.entries(filterModel)?.map(([key, item]) =>
|
? Object.entries(filterModel)?.map(([key, item]) =>
|
||||||
SQLBuilder.createFilterSql(
|
SQLBuilder.createFilterSql(
|
||||||
key === 'ag-Grid-AutoColumn' ? 'name' : key,
|
key === 'ag-Grid-AutoColumn' ? 'name' : key,
|
||||||
item,
|
item,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,138 +1,170 @@
|
||||||
export interface FieldCondition {
|
export interface FieldCondition {
|
||||||
field: string;
|
field: string;
|
||||||
op: OperatorType
|
op: OperatorType;
|
||||||
type?: "text" | "number" | "date";
|
type?: 'text' | 'number' | 'date';
|
||||||
value?: any;
|
value?: any;
|
||||||
valueTo?: any;
|
valueTo?: any;
|
||||||
};
|
}
|
||||||
export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in';
|
export type OperatorType =
|
||||||
export type LogicalCondition = FieldCondition | {
|
| 'equals'
|
||||||
AND?: LogicalCondition[];
|
| 'notEqual'
|
||||||
OR?: LogicalCondition[];
|
| '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 {
|
export function isFieldCondition(
|
||||||
return (condition as FieldCondition).field !== undefined;
|
condition: LogicalCondition,
|
||||||
|
): condition is FieldCondition {
|
||||||
|
return (condition as FieldCondition).field !== undefined;
|
||||||
}
|
}
|
||||||
function buildCondition(condition: FieldCondition): string {
|
function buildCondition(condition: FieldCondition): string {
|
||||||
const { field, op, value, type = "text", valueTo } = condition;
|
const { field, op, value, type = 'text', valueTo } = condition;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case 'equals':
|
case 'equals':
|
||||||
return `${field} = '${value}'`;
|
return `${field} = '${value}'`;
|
||||||
case 'notEqual':
|
case 'notEqual':
|
||||||
return `${field} != '${value}'`;
|
return `${field} != '${value}'`;
|
||||||
case 'contains':
|
case 'contains':
|
||||||
return `${field} LIKE '%${value}%'`;
|
return `${field} LIKE '%${value}%'`;
|
||||||
case 'startsWith':
|
case 'startsWith':
|
||||||
return `${field} LIKE '${value}%'`;
|
return `${field} LIKE '${value}%'`;
|
||||||
case 'endsWith':
|
case 'endsWith':
|
||||||
return `${field} LIKE '%${value}'`;
|
return `${field} LIKE '%${value}'`;
|
||||||
case 'blank':
|
case 'blank':
|
||||||
if (type !== "date")
|
if (type !== 'date') return `(${field} IS NULL OR ${field} = '')`;
|
||||||
return `(${field} IS NULL OR ${field} = '')`;
|
else return `${field} IS NULL`;
|
||||||
else
|
case 'notBlank':
|
||||||
return `${field} IS NULL`;
|
if (type !== 'date') return `${field} IS NOT NULL AND ${field} != ''`;
|
||||||
case 'notBlank':
|
else return `${field} IS NOT NULL`;
|
||||||
if (type !== 'date')
|
case 'greaterThan':
|
||||||
return `${field} IS NOT NULL AND ${field} != ''`;
|
return `${field} > '${value}'`;
|
||||||
else
|
case 'lessThanOrEqual':
|
||||||
return `${field} IS NOT NULL`;
|
return `${field} <= '${value}'`;
|
||||||
case 'greaterThan':
|
case 'lessThan':
|
||||||
return `${field} > '${value}'`;
|
return `${field} < '${value}'`;
|
||||||
case 'lessThanOrEqual':
|
case 'greaterThan':
|
||||||
return `${field} <= '${value}'`;
|
return `${field} > '${value}'`;
|
||||||
case 'lessThan':
|
case 'inRange':
|
||||||
return `${field} < '${value}'`;
|
return `${field} >= '${value}' AND ${field} <= '${valueTo}'`;
|
||||||
case 'greaterThan':
|
case 'in':
|
||||||
return `${field} > '${value}'`;
|
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||||
case 'inRange':
|
// Return a condition that is always false if value is empty or an empty array
|
||||||
return `${field} >= '${value}' AND ${field} <= '${valueTo}'`;
|
return '1 = 0';
|
||||||
case 'in':
|
}
|
||||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
return `${field} IN (${(value as any[]).map((val) => `'${val}'`).join(', ')})`;
|
||||||
// Return a condition that is always false if value is empty or an empty array
|
default:
|
||||||
return '1 = 0';
|
return 'true'; // Default return for unmatched conditions
|
||||||
}
|
}
|
||||||
return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`;
|
|
||||||
default:
|
|
||||||
return 'true'; // Default return for unmatched conditions
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function buildLogicalCondition(logicalCondition: LogicalCondition): string {
|
function buildLogicalCondition(logicalCondition: LogicalCondition): string {
|
||||||
if (isFieldCondition(logicalCondition)) {
|
if (isFieldCondition(logicalCondition)) {
|
||||||
return buildCondition(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 ')})`);
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
}
|
||||||
if (logicalCondition.AND && logicalCondition.AND.length > 0) {
|
// Process OR conditions
|
||||||
const andParts = logicalCondition.AND
|
if (logicalCondition.OR && logicalCondition.OR.length > 0) {
|
||||||
.map(c => buildLogicalCondition(c))
|
const orParts = logicalCondition.OR.map((c) =>
|
||||||
.filter(part => part !== ''); // Filter out empty conditions
|
buildLogicalCondition(c),
|
||||||
if (andParts.length > 0) {
|
).filter((part) => part !== ''); // Filter out empty conditions
|
||||||
parts.push(`(${andParts.join(' AND ')})`);
|
if (orParts.length > 0) {
|
||||||
}
|
parts.push(`(${orParts.join(' OR ')})`);
|
||||||
}
|
}
|
||||||
// Process OR conditions
|
}
|
||||||
if (logicalCondition.OR && logicalCondition.OR.length > 0) {
|
// Join AND and OR parts with an 'AND' if both are present
|
||||||
const orParts = logicalCondition.OR
|
return parts.length > 1 ? parts.join(' AND ') : parts[0] || '';
|
||||||
.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 {
|
export class SQLBuilder {
|
||||||
static select(fields: string[], distinctField?: string): string {
|
static select(fields: string[], distinctField?: string): string {
|
||||||
const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : "";
|
const distinctClause = distinctField
|
||||||
return `SELECT ${distinctClause}${fields.join(", ")}`;
|
? `DISTINCT ON (${distinctField}) `
|
||||||
}
|
: '';
|
||||||
static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string {
|
return `SELECT ${distinctClause}${fields.join(', ')}`;
|
||||||
if (!orderBy) {
|
}
|
||||||
throw new Error("orderBy 参数不能为空");
|
static rowNumber(
|
||||||
}
|
orderBy: string,
|
||||||
|
partitionBy: string | null = null,
|
||||||
let partitionClause = '';
|
alias: string = 'row_num',
|
||||||
if (partitionBy) {
|
): string {
|
||||||
partitionClause = `PARTITION BY ${partitionBy} `;
|
if (!orderBy) {
|
||||||
}
|
throw new Error('orderBy 参数不能为空');
|
||||||
|
|
||||||
return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`;
|
|
||||||
}
|
|
||||||
static from(tableName: string): string {
|
|
||||||
return `FROM ${tableName}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static where(conditions: LogicalCondition): string {
|
let partitionClause = '';
|
||||||
const whereClause = buildLogicalCondition(conditions);
|
if (partitionBy) {
|
||||||
return whereClause ? `WHERE ${whereClause}` : "";
|
partitionClause = `PARTITION BY ${partitionBy} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
static groupBy(columns: string[]): string {
|
return `ROW_NUMBER() OVER (${partitionClause}ORDER BY ${orderBy}) AS ${alias}`;
|
||||||
return columns.length ? `GROUP BY ${columns.join(", ")}` : "";
|
}
|
||||||
}
|
static from(tableName: string): string {
|
||||||
|
return `FROM ${tableName}`;
|
||||||
|
}
|
||||||
|
|
||||||
static orderBy(columns: string[]): string {
|
static where(conditions: LogicalCondition): string {
|
||||||
return columns.length ? `ORDER BY ${columns.join(", ")}` : "";
|
const whereClause = buildLogicalCondition(conditions);
|
||||||
}
|
return whereClause ? `WHERE ${whereClause}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
static limit(pageSize: number, offset: number = 0): string {
|
static groupBy(columns: string[]): string {
|
||||||
return `LIMIT ${pageSize + 1} OFFSET ${offset}`;
|
return columns.length ? `GROUP BY ${columns.join(', ')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
static join(clauses: string[]): string {
|
static orderBy(columns: string[]): string {
|
||||||
return clauses.filter(Boolean).join(' ');
|
return columns.length ? `ORDER BY ${columns.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)
|
|
||||||
|
|
||||||
}
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,13 @@ import { DepartmentModule } from '../department/department.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DepartmentModule],
|
imports: [DepartmentModule],
|
||||||
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter],
|
providers: [
|
||||||
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter]
|
RoleMapService,
|
||||||
|
RoleRouter,
|
||||||
|
TrpcService,
|
||||||
|
RoleService,
|
||||||
|
RoleMapRouter,
|
||||||
|
],
|
||||||
|
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter],
|
||||||
})
|
})
|
||||||
export class RoleMapModule { }
|
export class RoleMapModule {}
|
||||||
|
|
|
@ -3,86 +3,91 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
||||||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||||
import { RoleService } from './role.service';
|
import { RoleService } from './role.service';
|
||||||
import { z, ZodType } from 'zod';
|
import { z, ZodType } from 'zod';
|
||||||
const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any()
|
const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any();
|
||||||
const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any()
|
const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any();
|
||||||
const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any()
|
const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any();
|
||||||
const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any()
|
const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any();
|
||||||
const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any()
|
const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any();
|
||||||
const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any()
|
const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any();
|
||||||
const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any()
|
const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any();
|
||||||
const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any()
|
const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any();
|
||||||
const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any();
|
const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any();
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoleRouter {
|
export class RoleRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly roleService: RoleService,
|
private readonly roleService: RoleService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(RoleCreateArgsSchema)
|
.input(RoleCreateArgsSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.roleService.create(input, staff);
|
return await this.roleService.create(input, staff);
|
||||||
}),
|
}),
|
||||||
update: this.trpc.protectProcedure
|
update: this.trpc.protectProcedure
|
||||||
.input(RoleUpdateArgsSchema)
|
.input(RoleUpdateArgsSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.roleService.update(input, staff);
|
return await this.roleService.update(input, staff);
|
||||||
}),
|
}),
|
||||||
createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema))
|
createMany: this.trpc.protectProcedure
|
||||||
.mutation(async ({ ctx, input }) => {
|
.input(z.array(RoleCreateManyInputSchema))
|
||||||
const { staff } = ctx;
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { staff } = ctx;
|
||||||
|
|
||||||
return await this.roleService.createMany({ data: input }, staff);
|
return await this.roleService.createMany({ data: input }, staff);
|
||||||
}),
|
}),
|
||||||
softDeleteByIds: this.trpc.protectProcedure
|
softDeleteByIds: this.trpc.protectProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
ids: z.array(z.string()),
|
ids: z.array(z.string()),
|
||||||
data: RoleUpdateInputSchema.optional()
|
data: RoleUpdateInputSchema.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await this.roleService.softDeleteByIds(input.ids, input.data);
|
return await this.roleService.softDeleteByIds(input.ids, input.data);
|
||||||
}),
|
}),
|
||||||
findFirst: this.trpc.procedure
|
findFirst: this.trpc.procedure
|
||||||
.input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
.input(RoleFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await this.roleService.findFirst(input);
|
return await this.roleService.findFirst(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateOrder: this.trpc.protectProcedure
|
updateOrder: this.trpc.protectProcedure
|
||||||
.input(UpdateOrderSchema)
|
.input(UpdateOrderSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return this.roleService.updateOrder(input);
|
return this.roleService.updateOrder(input);
|
||||||
}),
|
}),
|
||||||
findMany: this.trpc.procedure
|
findMany: this.trpc.procedure
|
||||||
.input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
.input(RoleFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await this.roleService.findMany(input);
|
return await this.roleService.findMany(input);
|
||||||
}),
|
}),
|
||||||
findManyWithCursor: this.trpc.protectProcedure
|
findManyWithCursor: this.trpc.protectProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
cursor: z.any().nullish(),
|
z.object({
|
||||||
take: z.number().optional(),
|
cursor: z.any().nullish(),
|
||||||
where: RoleWhereInputSchema.optional(),
|
take: z.number().optional(),
|
||||||
select: RoleSelectSchema.optional()
|
where: RoleWhereInputSchema.optional(),
|
||||||
}))
|
select: RoleSelectSchema.optional(),
|
||||||
.query(async ({ ctx, input }) => {
|
}),
|
||||||
const { staff } = ctx;
|
)
|
||||||
return await this.roleService.findManyWithCursor(input);
|
.query(async ({ ctx, input }) => {
|
||||||
}),
|
const { staff } = ctx;
|
||||||
findManyWithPagination: this.trpc.procedure
|
return await this.roleService.findManyWithCursor(input);
|
||||||
.input(z.object({
|
}),
|
||||||
page: z.number(),
|
findManyWithPagination: this.trpc.procedure
|
||||||
pageSize: z.number().optional(),
|
.input(
|
||||||
where: RoleWhereInputSchema.optional(),
|
z.object({
|
||||||
select: RoleSelectSchema.optional()
|
page: z.number(),
|
||||||
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
pageSize: z.number().optional(),
|
||||||
.query(async ({ input }) => {
|
where: RoleWhereInputSchema.optional(),
|
||||||
return await this.roleService.findManyWithPagination(input);
|
select: RoleSelectSchema.optional(),
|
||||||
}),
|
}),
|
||||||
});
|
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return await this.roleService.findManyWithPagination(input);
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,47 +1,59 @@
|
||||||
import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nice/common";
|
import {
|
||||||
import { RowCacheService } from "../base/row-cache.service";
|
db,
|
||||||
import { isFieldCondition, LogicalCondition } from "../base/sql-builder";
|
ObjectType,
|
||||||
import { z } from "zod";
|
RowModelRequest,
|
||||||
|
RowRequestSchema,
|
||||||
|
UserProfile,
|
||||||
|
} from '@nice/common';
|
||||||
|
import { RowCacheService } from '../base/row-cache.service';
|
||||||
|
import { isFieldCondition, LogicalCondition } from '../base/sql-builder';
|
||||||
|
import { z } from 'zod';
|
||||||
export class RoleRowService extends RowCacheService {
|
export class RoleRowService extends RowCacheService {
|
||||||
protected createGetRowsFilters(
|
protected createGetRowsFilters(
|
||||||
request: z.infer<typeof RowRequestSchema>,
|
request: z.infer<typeof RowRequestSchema>,
|
||||||
staff?: UserProfile
|
staff?: UserProfile,
|
||||||
) {
|
) {
|
||||||
const condition = super.createGetRowsFilters(request)
|
const condition = super.createGetRowsFilters(request);
|
||||||
if (isFieldCondition(condition))
|
if (isFieldCondition(condition)) return {};
|
||||||
return {}
|
const baseModelCondition: LogicalCondition[] = [
|
||||||
const baseModelCondition: LogicalCondition[] = [{
|
{
|
||||||
field: `${this.tableName}.deleted_at`,
|
field: `${this.tableName}.deleted_at`,
|
||||||
op: "blank",
|
op: 'blank',
|
||||||
type: "date"
|
type: 'date',
|
||||||
}]
|
},
|
||||||
condition.AND = [...baseModelCondition, ...condition.AND!]
|
];
|
||||||
return condition
|
condition.AND = [...baseModelCondition, ...condition.AND!];
|
||||||
}
|
return condition;
|
||||||
createUnGroupingRowSelect(): string[] {
|
}
|
||||||
return [
|
createUnGroupingRowSelect(): string[] {
|
||||||
`${this.tableName}.id AS id`,
|
return [
|
||||||
`${this.tableName}.name AS name`,
|
`${this.tableName}.id AS id`,
|
||||||
`${this.tableName}.system AS system`,
|
`${this.tableName}.name AS name`,
|
||||||
`${this.tableName}.permissions AS permissions`
|
`${this.tableName}.system AS system`,
|
||||||
];
|
`${this.tableName}.permissions AS permissions`,
|
||||||
}
|
];
|
||||||
protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
|
}
|
||||||
if (!data.id)
|
protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
|
||||||
return data
|
if (!data.id) return data;
|
||||||
const roleMaps = await db.roleMap.findMany({
|
const roleMaps = await db.roleMap.findMany({
|
||||||
where: {
|
where: {
|
||||||
roleId: data.id
|
roleId: data.id,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId)
|
const deptIds = roleMaps
|
||||||
const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId)
|
.filter((item) => item.objectType === ObjectType.DEPARTMENT)
|
||||||
const depts = await db.department.findMany({ where: { id: { in: deptIds } } })
|
.map((roleMap) => roleMap.objectId);
|
||||||
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } })
|
const staffIds = roleMaps
|
||||||
const result = { ...data, depts, staffs }
|
.filter((item) => item.objectType === ObjectType.STAFF)
|
||||||
return result
|
.map((roleMap) => roleMap.objectId);
|
||||||
}
|
const depts = await db.department.findMany({
|
||||||
createJoinSql(request?: RowModelRequest): string[] {
|
where: { id: { in: deptIds } },
|
||||||
return [];
|
});
|
||||||
}
|
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } });
|
||||||
}
|
const result = { ...data, depts, staffs };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
createJoinSql(request?: RowModelRequest): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { TrpcService } from '@server/trpc/trpc.service';
|
import { TrpcService } from '@server/trpc/trpc.service';
|
||||||
import {
|
import { ObjectType, RoleMapMethodSchema } from '@nice/common';
|
||||||
ObjectType,
|
|
||||||
RoleMapMethodSchema,
|
|
||||||
} from '@nice/common';
|
|
||||||
import { RoleMapService } from './rolemap.service';
|
import { RoleMapService } from './rolemap.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -11,7 +8,7 @@ export class RoleMapRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly roleMapService: RoleMapService,
|
private readonly roleMapService: RoleMapService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
deleteAllRolesForObject: this.trpc.protectProcedure
|
deleteAllRolesForObject: this.trpc.protectProcedure
|
||||||
.input(RoleMapMethodSchema.deleteWithObject)
|
.input(RoleMapMethodSchema.deleteWithObject)
|
||||||
|
|
|
@ -64,10 +64,7 @@ export class RoleMapService extends RowModelService {
|
||||||
return condition;
|
return condition;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getRowDto(
|
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||||
row: any,
|
|
||||||
staff?: UserProfile,
|
|
||||||
): Promise<any> {
|
|
||||||
if (!row.id) return row;
|
if (!row.id) return row;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
@ -126,15 +123,17 @@ export class RoleMapService extends RowModelService {
|
||||||
data: roleMaps,
|
data: roleMaps,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const wrapResult = Promise.all(result.map(async item => {
|
const wrapResult = Promise.all(
|
||||||
const staff = await db.staff.findMany({
|
result.map(async (item) => {
|
||||||
include: { department: true },
|
const staff = await db.staff.findMany({
|
||||||
where: {
|
include: { department: true },
|
||||||
id: item.objectId
|
where: {
|
||||||
}
|
id: item.objectId,
|
||||||
})
|
},
|
||||||
return { ...item, staff }
|
});
|
||||||
}))
|
return { ...item, staff };
|
||||||
|
}),
|
||||||
|
);
|
||||||
return wrapResult;
|
return wrapResult;
|
||||||
}
|
}
|
||||||
async addRoleForObjects(
|
async addRoleForObjects(
|
||||||
|
@ -187,11 +186,11 @@ export class RoleMapService extends RowModelService {
|
||||||
{ objectId: staffId, objectType: ObjectType.STAFF },
|
{ objectId: staffId, objectType: ObjectType.STAFF },
|
||||||
...(deptId || ancestorDeptIds.length > 0
|
...(deptId || ancestorDeptIds.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
||||||
objectType: ObjectType.DEPARTMENT,
|
objectType: ObjectType.DEPARTMENT,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
// Helper function to fetch roles based on domain ID.
|
// Helper function to fetch roles based on domain ID.
|
||||||
|
@ -260,7 +259,9 @@ export class RoleMapService extends RowModelService {
|
||||||
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
|
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
|
||||||
return { items, totalCount };
|
return { items, totalCount };
|
||||||
}
|
}
|
||||||
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) {
|
async getStaffsNotMap(
|
||||||
|
data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>,
|
||||||
|
) {
|
||||||
const { domainId, roleId } = data;
|
const { domainId, roleId } = data;
|
||||||
let staffs = await db.staff.findMany({
|
let staffs = await db.staff.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -300,7 +301,9 @@ export class RoleMapService extends RowModelService {
|
||||||
* @param data 包含角色ID和域ID的数据
|
* @param data 包含角色ID和域ID的数据
|
||||||
* @returns 角色映射详情,包含部门ID和员工ID列表
|
* @returns 角色映射详情,包含部门ID和员工ID列表
|
||||||
*/
|
*/
|
||||||
async getRoleMapDetail(data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>) {
|
async getRoleMapDetail(
|
||||||
|
data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>,
|
||||||
|
) {
|
||||||
const { roleId, domainId } = data;
|
const { roleId, domainId } = data;
|
||||||
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
|
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
import path, { dirname } from "path";
|
import path, { dirname } from 'path';
|
||||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types";
|
import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
|
||||||
import { Resource, ResourceStatus, db } from "@nice/common";
|
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from '@nestjs/common';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
export abstract class BaseProcessor implements ResourceProcessor {
|
export abstract class BaseProcessor implements ResourceProcessor {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
protected logger = new Logger(BaseProcessor.name)
|
protected logger = new Logger(BaseProcessor.name);
|
||||||
|
|
||||||
abstract process(resource: Resource): Promise<Resource>
|
abstract process(resource: Resource): Promise<Resource>;
|
||||||
protected createOutputDir(filepath: string, subdirectory: string = 'assets'): string {
|
protected createOutputDir(
|
||||||
const outputDir = path.join(
|
filepath: string,
|
||||||
path.dirname(filepath),
|
subdirectory: string = 'assets',
|
||||||
subdirectory,
|
): string {
|
||||||
);
|
const outputDir = path.join(path.dirname(filepath), subdirectory);
|
||||||
fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`));
|
fs.mkdir(outputDir, { recursive: true }).catch((err) =>
|
||||||
|
this.logger.error(`Failed to create directory: ${err.message}`),
|
||||||
return outputDir;
|
);
|
||||||
|
|
||||||
}
|
return outputDir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { ResourceService } from './resource.service';
|
||||||
import { TrpcService } from '@server/trpc/trpc.service';
|
import { TrpcService } from '@server/trpc/trpc.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [ResourceRouter, ResourceService],
|
exports: [ResourceRouter, ResourceService],
|
||||||
providers: [ResourceRouter, ResourceService, TrpcService],
|
providers: [ResourceRouter, ResourceService, TrpcService],
|
||||||
})
|
})
|
||||||
export class ResourceModule { }
|
export class ResourceModule {}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Injectable ,Logger} from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { BaseService } from '../base/base.service';
|
import { BaseService } from '../base/base.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,55 +1,57 @@
|
||||||
import { Resource } from "@nice/common";
|
import { Resource } from '@nice/common';
|
||||||
|
|
||||||
export interface ResourceProcessor {
|
export interface ResourceProcessor {
|
||||||
process(resource: Resource): Promise<any>
|
process(resource: Resource): Promise<any>;
|
||||||
}
|
}
|
||||||
export interface ProcessResult {
|
export interface ProcessResult {
|
||||||
success: boolean
|
success: boolean;
|
||||||
resource: Resource
|
resource: Resource;
|
||||||
error?: Error
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseMetadata {
|
export interface BaseMetadata {
|
||||||
size: number
|
size: number;
|
||||||
filetype: string
|
filetype: string;
|
||||||
filename: string
|
filename: string;
|
||||||
extension: string
|
extension: string;
|
||||||
modifiedAt: Date
|
modifiedAt: Date;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 图片特有元数据接口
|
* 图片特有元数据接口
|
||||||
*/
|
*/
|
||||||
export interface ImageMetadata {
|
export interface ImageMetadata {
|
||||||
width: number; // 图片宽度(px)
|
width: number; // 图片宽度(px)
|
||||||
height: number; // 图片高度(px)
|
height: number; // 图片高度(px)
|
||||||
compressedUrl?: string;
|
compressedUrl?: string;
|
||||||
orientation?: number; // EXIF方向信息
|
orientation?: number; // EXIF方向信息
|
||||||
space?: string; // 色彩空间 (如: RGB, CMYK)
|
space?: string; // 色彩空间 (如: RGB, CMYK)
|
||||||
hasAlpha?: boolean; // 是否包含透明通道
|
hasAlpha?: boolean; // 是否包含透明通道
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频特有元数据接口
|
* 视频特有元数据接口
|
||||||
*/
|
*/
|
||||||
export interface VideoMetadata {
|
export interface VideoMetadata {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
videoCodec?: string;
|
videoCodec?: string;
|
||||||
audioCodec?: string;
|
audioCodec?: string;
|
||||||
coverUrl?: string
|
coverUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 音频特有元数据接口
|
* 音频特有元数据接口
|
||||||
*/
|
*/
|
||||||
export interface AudioMetadata {
|
export interface AudioMetadata {
|
||||||
duration: number; // 音频时长(秒)
|
duration: number; // 音频时长(秒)
|
||||||
bitrate?: number; // 比特率(bps)
|
bitrate?: number; // 比特率(bps)
|
||||||
sampleRate?: number; // 采样率(Hz)
|
sampleRate?: number; // 采样率(Hz)
|
||||||
channels?: number; // 声道数
|
channels?: number; // 声道数
|
||||||
codec?: string; // 音频编码格式
|
codec?: string; // 音频编码格式
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FileMetadata = ImageMetadata &
|
||||||
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata
|
VideoMetadata &
|
||||||
|
AudioMetadata &
|
||||||
|
BaseMetadata;
|
||||||
|
|
|
@ -12,4 +12,4 @@ import { StaffRowService } from './staff.row.service';
|
||||||
exports: [StaffService, StaffRouter, StaffRowService],
|
exports: [StaffService, StaffRouter, StaffRowService],
|
||||||
controllers: [StaffController],
|
controllers: [StaffController],
|
||||||
})
|
})
|
||||||
export class StaffModule { }
|
export class StaffModule {}
|
||||||
|
|
|
@ -8,7 +8,7 @@ export class TaxonomyRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly taxonomyService: TaxonomyService,
|
private readonly taxonomyService: TaxonomyService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.procedure
|
create: this.trpc.procedure
|
||||||
|
|
|
@ -13,4 +13,4 @@ import { TermRowService } from './term.row.service';
|
||||||
exports: [TermService, TermRouter],
|
exports: [TermService, TermRouter],
|
||||||
controllers: [TermController],
|
controllers: [TermController],
|
||||||
})
|
})
|
||||||
export class TermModule { }
|
export class TermModule {}
|
||||||
|
|
|
@ -8,12 +8,7 @@ import { DepartmentModule } from '../department/department.module';
|
||||||
import { StaffModule } from '../staff/staff.module';
|
import { StaffModule } from '../staff/staff.module';
|
||||||
// import { TransformController } from './transform.controller';
|
// import { TransformController } from './transform.controller';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [DepartmentModule, StaffModule, TermModule, TaxonomyModule],
|
||||||
DepartmentModule,
|
|
||||||
StaffModule,
|
|
||||||
TermModule,
|
|
||||||
TaxonomyModule,
|
|
||||||
],
|
|
||||||
providers: [TransformService, TransformRouter, TrpcService],
|
providers: [TransformService, TransformRouter, TrpcService],
|
||||||
exports: [TransformRouter, TransformService],
|
exports: [TransformRouter, TransformService],
|
||||||
// controllers:[TransformController]
|
// controllers:[TransformController]
|
||||||
|
|
|
@ -1,205 +1,210 @@
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { Logger } from '@nestjs/common';
|
||||||
import { Logger } from "@nestjs/common";
|
import { WebSocketServerConfig, WSClient, WebSocketType } from '../types';
|
||||||
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
|
|
||||||
import { SocketMessage } from '@nice/common';
|
import { SocketMessage } from '@nice/common';
|
||||||
|
|
||||||
const DEFAULT_CONFIG: WebSocketServerConfig = {
|
const DEFAULT_CONFIG: WebSocketServerConfig = {
|
||||||
pingInterval: 30000,
|
pingInterval: 30000,
|
||||||
pingTimeout: 5000,
|
pingTimeout: 5000,
|
||||||
debug: false, // 新增默认调试配置
|
debug: false, // 新增默认调试配置
|
||||||
};
|
};
|
||||||
interface IWebSocketServer {
|
interface IWebSocketServer {
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
broadcast(data: any): void;
|
broadcast(data: any): void;
|
||||||
handleConnection(ws: WSClient): void;
|
handleConnection(ws: WSClient): void;
|
||||||
handleDisconnection(ws: WSClient): void;
|
handleDisconnection(ws: WSClient): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseWebSocketServer implements IWebSocketServer {
|
export abstract class BaseWebSocketServer implements IWebSocketServer {
|
||||||
private _wss: WebSocketServer | null = null;
|
private _wss: WebSocketServer | null = null;
|
||||||
protected clients: Set<WSClient> = new Set();
|
protected clients: Set<WSClient> = new Set();
|
||||||
protected timeouts: Map<WSClient, NodeJS.Timeout> = new Map();
|
protected timeouts: Map<WSClient, NodeJS.Timeout> = new Map();
|
||||||
protected pingIntervalId?: NodeJS.Timeout;
|
protected pingIntervalId?: NodeJS.Timeout;
|
||||||
protected readonly logger = new Logger(this.constructor.name);
|
protected readonly logger = new Logger(this.constructor.name);
|
||||||
protected readonly finalConfig: WebSocketServerConfig;
|
protected readonly finalConfig: WebSocketServerConfig;
|
||||||
private userClientMap: Map<string, WSClient> = new Map();
|
private userClientMap: Map<string, WSClient> = new Map();
|
||||||
constructor(
|
constructor(protected readonly config: Partial<WebSocketServerConfig> = {}) {
|
||||||
protected readonly config: Partial<WebSocketServerConfig> = {}
|
this.finalConfig = {
|
||||||
) {
|
...DEFAULT_CONFIG,
|
||||||
this.finalConfig = {
|
...config,
|
||||||
...DEFAULT_CONFIG,
|
};
|
||||||
...config,
|
}
|
||||||
};
|
protected debugLog(message: string, ...optionalParams: any[]): void {
|
||||||
|
if (this.finalConfig.debug) {
|
||||||
|
this.logger.debug(message, ...optionalParams);
|
||||||
}
|
}
|
||||||
protected debugLog(message: string, ...optionalParams: any[]): void {
|
}
|
||||||
if (this.finalConfig.debug) {
|
public getClientCount() {
|
||||||
this.logger.debug(message, ...optionalParams);
|
return this.clients.size;
|
||||||
}
|
}
|
||||||
}
|
// 暴露 WebSocketServer 实例的只读访问
|
||||||
public getClientCount() {
|
public get wss(): WebSocketServer | null {
|
||||||
return this.clients.size
|
return this._wss;
|
||||||
}
|
}
|
||||||
// 暴露 WebSocketServer 实例的只读访问
|
|
||||||
public get wss(): WebSocketServer | null {
|
// 内部使用的 setter
|
||||||
return this._wss;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内部使用的 setter
|
this.clients.forEach((client) => client.close());
|
||||||
protected set wss(value: WebSocketServer | null) {
|
this.clients.clear();
|
||||||
this._wss = value;
|
this.timeouts.clear();
|
||||||
|
|
||||||
|
if (this._wss) {
|
||||||
|
await new Promise((resolve) => this._wss!.close(resolve));
|
||||||
|
this._wss = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract get serverType(): WebSocketType;
|
this.debugLog(`WebSocket server stopped on path: ${this.serverPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
public get serverPath(): string {
|
public broadcast(data: SocketMessage): void {
|
||||||
return this.finalConfig.path || `/${this.serverType}`;
|
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);
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||||
if (this._wss) await this.stop();
|
this.debugLog(`
|
||||||
|
|
||||||
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
|
[${this.serverType}] connected
|
||||||
userId ${ws.userId}
|
userId ${ws.userId}
|
||||||
roomId ${ws.roomId}
|
roomId ${ws.roomId}
|
||||||
room clients ${roomClientsCount}
|
room clients ${roomClientsCount}
|
||||||
total clients ${this.clients.size}`);
|
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();
|
||||||
|
|
||||||
public handleDisconnection(ws: WSClient): void {
|
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
||||||
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.debugLog(`
|
|
||||||
[${this.serverType}] disconnected
|
[${this.serverType}] disconnected
|
||||||
userId ${ws.userId}
|
userId ${ws.userId}
|
||||||
roomId ${ws.roomId}
|
roomId ${ws.roomId}
|
||||||
room clients ${roomClientsCount}
|
room clients ${roomClientsCount}
|
||||||
total clients ${this.clients.size}`);
|
total clients ${this.clients.size}`);
|
||||||
}
|
}
|
||||||
protected setupClientEvents(ws: WSClient): void {
|
protected setupClientEvents(ws: WSClient): void {
|
||||||
ws.on('pong', () => this.handlePong(ws))
|
ws.on('pong', () => this.handlePong(ws))
|
||||||
.on('close', () => this.handleDisconnection(ws))
|
.on('close', () => this.handleDisconnection(ws))
|
||||||
.on('error', (error) => {
|
.on('error', (error) => {
|
||||||
this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error);
|
this.logger.error(
|
||||||
this.handleDisconnection(ws);
|
`[${this.serverType}] client error on path ${this.serverPath}:`,
|
||||||
});
|
error,
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
);
|
||||||
}
|
this.handleDisconnection(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private pingClients(): void {
|
private handlePong(ws: WSClient): void {
|
||||||
this.clients.forEach(ws => {
|
ws.isAlive = true;
|
||||||
if (!ws.isAlive) return this.handleDisconnection(ws);
|
const timeout = this.timeouts.get(ws);
|
||||||
|
if (timeout) {
|
||||||
ws.isAlive = false;
|
clearTimeout(timeout);
|
||||||
ws.ping();
|
this.timeouts.delete(ws);
|
||||||
const timeout = setTimeout(
|
|
||||||
() => !ws.isAlive && this.handleDisconnection(ws),
|
|
||||||
this.finalConfig.pingTimeout
|
|
||||||
);
|
|
||||||
this.timeouts.set(ws, timeout);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected setupServerEvents(): void {
|
private startPingInterval(): void {
|
||||||
if (!this._wss) return;
|
this.pingIntervalId = setInterval(
|
||||||
this._wss
|
() => this.pingClients(),
|
||||||
.on('connection', (ws: WSClient) => this.handleConnection(ws))
|
this.finalConfig.pingInterval,
|
||||||
.on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,13 @@ import http from 'http';
|
||||||
import { parseInt as libParseInt } from 'lib0/number';
|
import { parseInt as libParseInt } from 'lib0/number';
|
||||||
import { WSSharedDoc } from './ws-shared-doc';
|
import { WSSharedDoc } from './ws-shared-doc';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回调URL配置,从环境变量中获取
|
* 回调URL配置,从环境变量中获取
|
||||||
* 如果环境变量未设置则为null
|
* 如果环境变量未设置则为null
|
||||||
*/
|
*/
|
||||||
const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null;
|
const CALLBACK_URL = process.env.CALLBACK_URL
|
||||||
|
? new URL(process.env.CALLBACK_URL)
|
||||||
|
: null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回调超时时间配置,从环境变量中获取
|
* 回调超时时间配置,从环境变量中获取
|
||||||
|
@ -25,7 +26,9 @@ const CALLBACK_TIMEOUT = libParseInt(process.env.CALLBACK_TIMEOUT || '5000');
|
||||||
* 需要监听变更的共享对象配置
|
* 需要监听变更的共享对象配置
|
||||||
* 从环境变量CALLBACK_OBJECTS中解析JSON格式的配置
|
* 从环境变量CALLBACK_OBJECTS中解析JSON格式的配置
|
||||||
*/
|
*/
|
||||||
const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {};
|
const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS
|
||||||
|
? JSON.parse(process.env.CALLBACK_OBJECTS)
|
||||||
|
: {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出回调URL是否已配置的标志
|
* 导出回调URL是否已配置的标志
|
||||||
|
@ -37,10 +40,13 @@ export const isCallbackSet = !!CALLBACK_URL;
|
||||||
*/
|
*/
|
||||||
interface DataToSend {
|
interface DataToSend {
|
||||||
room: string; // 房间/文档标识
|
room: string; // 房间/文档标识
|
||||||
data: Record<string, {
|
data: Record<
|
||||||
type: string; // 数据类型
|
string,
|
||||||
content: any; // 数据内容
|
{
|
||||||
}>;
|
type: string; // 数据类型
|
||||||
|
content: any; // 数据内容
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -59,25 +65,29 @@ type OriginType = any;
|
||||||
* @param origin - 更新的来源
|
* @param origin - 更新的来源
|
||||||
* @param doc - 共享文档实例
|
* @param doc - 共享文档实例
|
||||||
*/
|
*/
|
||||||
export const callbackHandler = (update: UpdateType, origin: OriginType, doc: WSSharedDoc): void => {
|
export const callbackHandler = (
|
||||||
|
update: UpdateType,
|
||||||
|
origin: OriginType,
|
||||||
|
doc: WSSharedDoc,
|
||||||
|
): void => {
|
||||||
// 获取文档名称作为房间标识
|
// 获取文档名称作为房间标识
|
||||||
const room = doc.name;
|
const room = doc.name;
|
||||||
|
|
||||||
// 初始化要发送的数据对象
|
// 初始化要发送的数据对象
|
||||||
const dataToSend: DataToSend = {
|
const dataToSend: DataToSend = {
|
||||||
room,
|
room,
|
||||||
data: {}
|
data: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取所有需要监听的共享对象名称
|
// 获取所有需要监听的共享对象名称
|
||||||
const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
|
const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
|
||||||
|
|
||||||
// 遍历所有共享对象,获取它们的最新内容
|
// 遍历所有共享对象,获取它们的最新内容
|
||||||
sharedObjectList.forEach(sharedObjectName => {
|
sharedObjectList.forEach((sharedObjectName) => {
|
||||||
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
|
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
|
||||||
dataToSend.data[sharedObjectName] = {
|
dataToSend.data[sharedObjectName] = {
|
||||||
type: sharedObjectType,
|
type: sharedObjectType,
|
||||||
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON()
|
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -106,8 +116,8 @@ const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(dataString)
|
'Content-Length': Buffer.byteLength(dataString),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建HTTP请求
|
// 创建HTTP请求
|
||||||
|
@ -137,14 +147,24 @@ const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
|
||||||
* @param doc - 共享文档实例
|
* @param doc - 共享文档实例
|
||||||
* @returns 共享对象的内容
|
* @returns 共享对象的内容
|
||||||
*/
|
*/
|
||||||
const getContent = (objName: string, objType: string, doc: WSSharedDoc): any => {
|
const getContent = (
|
||||||
|
objName: string,
|
||||||
|
objType: string,
|
||||||
|
doc: WSSharedDoc,
|
||||||
|
): any => {
|
||||||
// 根据对象类型返回相应的共享对象
|
// 根据对象类型返回相应的共享对象
|
||||||
switch (objType) {
|
switch (objType) {
|
||||||
case 'Array': return doc.getArray(objName);
|
case 'Array':
|
||||||
case 'Map': return doc.getMap(objName);
|
return doc.getArray(objName);
|
||||||
case 'Text': return doc.getText(objName);
|
case 'Map':
|
||||||
case 'XmlFragment': return doc.getXmlFragment(objName);
|
return doc.getMap(objName);
|
||||||
case 'XmlElement': return doc.getXmlElement(objName);
|
case 'Text':
|
||||||
default: return {};
|
return doc.getText(objName);
|
||||||
|
case 'XmlFragment':
|
||||||
|
return doc.getXmlFragment(objName);
|
||||||
|
case 'XmlElement':
|
||||||
|
return doc.getXmlElement(objName);
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,6 +3,6 @@ import { YjsServer } from './yjs.server';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [YjsServer],
|
providers: [YjsServer],
|
||||||
exports: [YjsServer]
|
exports: [YjsServer],
|
||||||
})
|
})
|
||||||
export class CollaborationModule { }
|
export class CollaborationModule {}
|
||||||
|
|
|
@ -23,7 +23,7 @@ if (typeof persistenceDir === 'string') {
|
||||||
ldb.storeUpdate(docName, update);
|
ldb.storeUpdate(docName, update);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
writeState: async (_docName, _ydoc) => { },
|
writeState: async (_docName, _ydoc) => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
export interface ConnectionOptions {
|
export interface ConnectionOptions {
|
||||||
docName: string;
|
docName: string;
|
||||||
gc: boolean;
|
gc: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,158 +1,187 @@
|
||||||
import { readSyncMessage } from '@nice/common';
|
import { readSyncMessage } from '@nice/common';
|
||||||
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates, writeSyncStep1, writeUpdate } from '@nice/common';
|
import {
|
||||||
|
applyAwarenessUpdate,
|
||||||
|
Awareness,
|
||||||
|
encodeAwarenessUpdate,
|
||||||
|
removeAwarenessStates,
|
||||||
|
writeSyncStep1,
|
||||||
|
writeUpdate,
|
||||||
|
} from '@nice/common';
|
||||||
import * as encoding from 'lib0/encoding';
|
import * as encoding from 'lib0/encoding';
|
||||||
import * as decoding from 'lib0/decoding';
|
import * as decoding from 'lib0/decoding';
|
||||||
import * as Y from "yjs"
|
import * as Y from 'yjs';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { getPersistence, setPersistence } from './persistence';
|
import { getPersistence, setPersistence } from './persistence';
|
||||||
import { callbackHandler, isCallbackSet } from './callback';
|
import { callbackHandler, isCallbackSet } from './callback';
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from 'ws';
|
||||||
import { YMessageType } from '@nice/common';
|
import { YMessageType } from '@nice/common';
|
||||||
import { WSClient } from '../types';
|
import { WSClient } from '../types';
|
||||||
export const docs = new Map<string, WSSharedDoc>();
|
export const docs = new Map<string, WSSharedDoc>();
|
||||||
export const CALLBACK_DEBOUNCE_WAIT = parseInt(process.env.CALLBACK_DEBOUNCE_WAIT || '2000');
|
export const CALLBACK_DEBOUNCE_WAIT = parseInt(
|
||||||
export const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000');
|
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 => {
|
export const getYDoc = (docname: string, gc = true): WSSharedDoc => {
|
||||||
return docs.get(docname) || createYDoc(docname, gc);
|
return docs.get(docname) || createYDoc(docname, gc);
|
||||||
};
|
};
|
||||||
const createYDoc = (docname: string, gc: boolean): WSSharedDoc => {
|
const createYDoc = (docname: string, gc: boolean): WSSharedDoc => {
|
||||||
const doc = new WSSharedDoc(docname, gc);
|
const doc = new WSSharedDoc(docname, gc);
|
||||||
docs.set(docname, doc);
|
docs.set(docname, doc);
|
||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const send = (doc: WSSharedDoc, conn: WebSocket, m: Uint8Array) => {
|
export const send = (doc: WSSharedDoc, conn: WebSocket, m: Uint8Array) => {
|
||||||
if (conn.readyState !== WebSocket.OPEN) {
|
if (conn.readyState !== WebSocket.OPEN) {
|
||||||
closeConn(doc, conn);
|
closeConn(doc, conn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
conn.send(m, {}, err => { err != null && closeConn(doc, conn) });
|
conn.send(m, {}, (err) => {
|
||||||
} catch (e) {
|
err != null && closeConn(doc, conn);
|
||||||
closeConn(doc, conn);
|
});
|
||||||
}
|
} catch (e) {
|
||||||
|
closeConn(doc, conn);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => {
|
export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => {
|
||||||
if (doc.conns.has(conn)) {
|
if (doc.conns.has(conn)) {
|
||||||
const controlledIds = doc.conns.get(conn) as Set<number>;
|
const controlledIds = doc.conns.get(conn) as Set<number>;
|
||||||
doc.conns.delete(conn);
|
doc.conns.delete(conn);
|
||||||
removeAwarenessStates(
|
removeAwarenessStates(doc.awareness, Array.from(controlledIds), null);
|
||||||
doc.awareness,
|
|
||||||
Array.from(controlledIds),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (doc.conns.size === 0 && getPersistence() !== null) {
|
if (doc.conns.size === 0 && getPersistence() !== null) {
|
||||||
getPersistence()?.writeState(doc.name, doc).then(() => {
|
getPersistence()
|
||||||
doc.destroy();
|
?.writeState(doc.name, doc)
|
||||||
});
|
.then(() => {
|
||||||
docs.delete(doc.name);
|
doc.destroy();
|
||||||
}
|
});
|
||||||
|
docs.delete(doc.name);
|
||||||
}
|
}
|
||||||
conn.close();
|
}
|
||||||
|
conn.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8Array) => {
|
export const messageListener = (
|
||||||
try {
|
conn: WSClient,
|
||||||
const encoder = encoding.createEncoder();
|
doc: WSSharedDoc,
|
||||||
const decoder = decoding.createDecoder(message);
|
message: Uint8Array,
|
||||||
const messageType = decoding.readVarUint(decoder);
|
) => {
|
||||||
switch (messageType) {
|
try {
|
||||||
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();
|
const encoder = encoding.createEncoder();
|
||||||
encoding.writeVarUint(encoder, YMessageType.Sync);
|
const decoder = decoding.createDecoder(message);
|
||||||
writeUpdate(encoder, update);
|
const messageType = decoding.readVarUint(decoder);
|
||||||
const message = encoding.toUint8Array(encoder);
|
switch (messageType) {
|
||||||
doc.conns.forEach((_, conn) => send(doc, conn, message));
|
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]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let contentInitializor: (ydoc: Y.Doc) => Promise<void> = (_ydoc) => Promise.resolve();
|
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>) => {
|
export const setContentInitializor = (f: (ydoc: Y.Doc) => Promise<void>) => {
|
||||||
contentInitializor = f;
|
contentInitializor = f;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class WSSharedDoc extends Y.Doc {
|
export class WSSharedDoc extends Y.Doc {
|
||||||
name: string;
|
name: string;
|
||||||
conns: Map<WebSocket, Set<number>>;
|
conns: Map<WebSocket, Set<number>>;
|
||||||
awareness: Awareness;
|
awareness: Awareness;
|
||||||
whenInitialized: Promise<void>;
|
whenInitialized: Promise<void>;
|
||||||
|
|
||||||
constructor(name: string, gc: boolean) {
|
constructor(name: string, gc: boolean) {
|
||||||
super({ gc });
|
super({ gc });
|
||||||
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.conns = new Map();
|
this.conns = new Map();
|
||||||
this.awareness = new Awareness(this);
|
this.awareness = new Awareness(this);
|
||||||
this.awareness.setLocalState(null);
|
this.awareness.setLocalState(null);
|
||||||
|
|
||||||
const awarenessUpdateHandler = ({
|
const awarenessUpdateHandler = (
|
||||||
added,
|
{
|
||||||
updated,
|
added,
|
||||||
removed
|
updated,
|
||||||
}: {
|
removed,
|
||||||
added: number[],
|
}: {
|
||||||
updated: number[],
|
added: number[];
|
||||||
removed: number[]
|
updated: number[];
|
||||||
}, conn: WebSocket) => {
|
removed: number[];
|
||||||
const changedClients = added.concat(updated, removed);
|
},
|
||||||
if (changedClients.length === 0) return
|
conn: WebSocket,
|
||||||
if (conn !== null) {
|
) => {
|
||||||
const connControlledIDs = this.conns.get(conn) as Set<number>;
|
const changedClients = added.concat(updated, removed);
|
||||||
if (connControlledIDs !== undefined) {
|
if (changedClients.length === 0) return;
|
||||||
added.forEach(clientID => { connControlledIDs.add(clientID); });
|
if (conn !== null) {
|
||||||
removed.forEach(clientID => { connControlledIDs.delete(clientID); });
|
const connControlledIDs = this.conns.get(conn) as Set<number>;
|
||||||
}
|
if (connControlledIDs !== undefined) {
|
||||||
}
|
added.forEach((clientID) => {
|
||||||
|
connControlledIDs.add(clientID);
|
||||||
const encoder = encoding.createEncoder();
|
});
|
||||||
encoding.writeVarUint(encoder, YMessageType.Awareness);
|
removed.forEach((clientID) => {
|
||||||
encoding.writeVarUint8Array(
|
connControlledIDs.delete(clientID);
|
||||||
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,85 +1,117 @@
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from '@nestjs/common';
|
||||||
import { WebSocketType, WSClient } from "../types";
|
import { WebSocketType, WSClient } from '../types';
|
||||||
import { BaseWebSocketServer } from "../base/base-websocket-server";
|
import { BaseWebSocketServer } from '../base/base-websocket-server';
|
||||||
import { encoding } from "lib0";
|
import { encoding } from 'lib0';
|
||||||
import { YMessageType, writeSyncStep1, encodeAwarenessUpdate } from "@nice/common";
|
import {
|
||||||
import { getYDoc, closeConn, WSSharedDoc, messageListener, send } from "./ws-shared-doc";
|
YMessageType,
|
||||||
|
writeSyncStep1,
|
||||||
|
encodeAwarenessUpdate,
|
||||||
|
} from '@nice/common';
|
||||||
|
import {
|
||||||
|
getYDoc,
|
||||||
|
closeConn,
|
||||||
|
WSSharedDoc,
|
||||||
|
messageListener,
|
||||||
|
send,
|
||||||
|
} from './ws-shared-doc';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YjsServer extends BaseWebSocketServer {
|
export class YjsServer extends BaseWebSocketServer {
|
||||||
public get serverType(): WebSocketType {
|
public get serverType(): WebSocketType {
|
||||||
return WebSocketType.YJS;
|
return WebSocketType.YJS;
|
||||||
}
|
}
|
||||||
public override handleConnection(
|
public override handleConnection(connection: WSClient): void {
|
||||||
connection: WSClient
|
super.handleConnection(connection);
|
||||||
): void {
|
try {
|
||||||
super.handleConnection(connection)
|
connection.binaryType = 'arraybuffer';
|
||||||
try {
|
const doc = this.initializeDocument(connection, connection.roomId, true);
|
||||||
connection.binaryType = 'arraybuffer';
|
this.setupConnectionHandlers(connection, doc);
|
||||||
const doc = this.initializeDocument(connection, connection.roomId, true);
|
this.sendInitialSync(connection, doc);
|
||||||
this.setupConnectionHandlers(connection, doc);
|
} catch (error: any) {
|
||||||
this.sendInitialSync(connection, doc);
|
this.logger.error(
|
||||||
} catch (error: any) {
|
`Error in handleNewConnection: ${error.message}`,
|
||||||
this.logger.error(`Error in handleNewConnection: ${error.message}`, error.stack);
|
error.stack,
|
||||||
connection.close();
|
);
|
||||||
}
|
connection.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private initializeDocument(conn: WSClient, docName: string, gc: boolean) {
|
private initializeDocument(conn: WSClient, docName: string, gc: boolean) {
|
||||||
const doc = getYDoc(docName, gc);
|
const doc = getYDoc(docName, gc);
|
||||||
|
|
||||||
doc.conns.set(conn, new Set());
|
doc.conns.set(conn, new Set());
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupConnectionHandlers(connection: WSClient, doc: WSSharedDoc): void {
|
private setupConnectionHandlers(
|
||||||
connection.on('message', (message: ArrayBuffer) => {
|
connection: WSClient,
|
||||||
this.handleMessage(connection, doc, message);
|
doc: WSSharedDoc,
|
||||||
});
|
): void {
|
||||||
connection.on('close', () => {
|
connection.on('message', (message: ArrayBuffer) => {
|
||||||
this.handleClose(doc, connection);
|
this.handleMessage(connection, doc, message);
|
||||||
});
|
});
|
||||||
connection.on('error', (error) => {
|
connection.on('close', () => {
|
||||||
this.logger.error(`WebSocket error for doc ${doc.name}: ${error.message}`, error.stack);
|
this.handleClose(doc, connection);
|
||||||
closeConn(doc, connection);
|
});
|
||||||
this.logger.warn(`Connection closed due to error for doc: ${doc.name}. Remaining connections: ${doc.conns.size}`);
|
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 {
|
private handleClose(doc: WSSharedDoc, connection: WSClient): void {
|
||||||
try {
|
try {
|
||||||
closeConn(doc, connection);
|
closeConn(doc, connection);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Error closing connection: ${error.message}`, error.stack);
|
this.logger.error(
|
||||||
}
|
`Error closing connection: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
private handleMessage(connection: WSClient, doc: WSSharedDoc, message: ArrayBuffer): void {
|
}
|
||||||
try {
|
private handleMessage(
|
||||||
messageListener(connection, doc, new Uint8Array(message));
|
connection: WSClient,
|
||||||
} catch (error: any) {
|
doc: WSSharedDoc,
|
||||||
this.logger.error(`Error handling message: ${error.message}`, error.stack);
|
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);
|
private sendInitialSync(connection: WSClient, doc: any): void {
|
||||||
this.sendAwarenessStates(connection, doc);
|
this.sendSyncStep1(connection, doc);
|
||||||
}
|
this.sendAwarenessStates(connection, doc);
|
||||||
private sendSyncStep1(connection: WSClient, doc: any): void {
|
}
|
||||||
const encoder = encoding.createEncoder();
|
private sendSyncStep1(connection: WSClient, doc: any): void {
|
||||||
encoding.writeVarUint(encoder, YMessageType.Sync);
|
const encoder = encoding.createEncoder();
|
||||||
writeSyncStep1(encoder, doc);
|
encoding.writeVarUint(encoder, YMessageType.Sync);
|
||||||
send(doc, connection, encoding.toUint8Array(encoder));
|
writeSyncStep1(encoder, doc);
|
||||||
}
|
send(doc, connection, encoding.toUint8Array(encoder));
|
||||||
private sendAwarenessStates(connection: WSClient, doc: WSSharedDoc): void {
|
}
|
||||||
const awarenessStates = doc.awareness.getStates();
|
private sendAwarenessStates(connection: WSClient, doc: WSSharedDoc): void {
|
||||||
|
const awarenessStates = doc.awareness.getStates();
|
||||||
|
|
||||||
if (awarenessStates.size > 0) {
|
if (awarenessStates.size > 0) {
|
||||||
const encoder = encoding.createEncoder();
|
const encoder = encoding.createEncoder();
|
||||||
encoding.writeVarUint(encoder, YMessageType.Awareness);
|
encoding.writeVarUint(encoder, YMessageType.Awareness);
|
||||||
encoding.writeVarUint8Array(
|
encoding.writeVarUint8Array(
|
||||||
encoder,
|
encoder,
|
||||||
encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))
|
encodeAwarenessUpdate(
|
||||||
);
|
doc.awareness,
|
||||||
send(doc, connection, encoding.toUint8Array(encoder));
|
Array.from(awarenessStates.keys()),
|
||||||
}
|
),
|
||||||
|
);
|
||||||
|
send(doc, connection, encoding.toUint8Array(encoder));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { RealtimeServer } from './realtime.server';
|
import { RealtimeServer } from './realtime.server';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [ RealtimeServer],
|
providers: [RealtimeServer],
|
||||||
exports: [ RealtimeServer]
|
exports: [RealtimeServer],
|
||||||
})
|
})
|
||||||
export class RealTimeModule { }
|
export class RealTimeModule {}
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { WebSocketType } from "../types";
|
import { WebSocketType } from '../types';
|
||||||
import { BaseWebSocketServer } from "../base/base-websocket-server";
|
import { BaseWebSocketServer } from '../base/base-websocket-server';
|
||||||
import EventBus, { CrudOperation } from "@server/utils/event-bus";
|
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
import { ObjectType, SocketMsgType } from "@nice/common";
|
import { ObjectType, SocketMsgType } from '@nice/common';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit {
|
export class RealtimeServer
|
||||||
onModuleInit() {
|
extends BaseWebSocketServer
|
||||||
EventBus.on("dataChanged", ({ data, type, operation }) => {
|
implements OnModuleInit
|
||||||
// if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
{
|
||||||
// const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
|
onModuleInit() {
|
||||||
// this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
|
EventBus.on('dataChanged', ({ data, type, operation }) => {
|
||||||
// }
|
// if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
|
||||||
|
// const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
|
||||||
// if (type === ObjectType.POST) {
|
// this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
|
||||||
// const post = data as Partial<PostDto>
|
// }
|
||||||
|
// if (type === ObjectType.POST) {
|
||||||
// }
|
// const post = data as Partial<PostDto>
|
||||||
})
|
// }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
public get serverType(): WebSocketType {
|
public get serverType(): WebSocketType {
|
||||||
return WebSocketType.REALTIME;
|
return WebSocketType.REALTIME;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,29 @@
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
export enum WebSocketType {
|
export enum WebSocketType {
|
||||||
YJS = "yjs",
|
YJS = 'yjs',
|
||||||
REALTIME = "realtime"
|
REALTIME = 'realtime',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebSocketServerConfig {
|
export interface WebSocketServerConfig {
|
||||||
path?: string;
|
path?: string;
|
||||||
pingInterval?: number;
|
pingInterval?: number;
|
||||||
pingTimeout?: number;
|
pingTimeout?: number;
|
||||||
debug?: boolean
|
debug?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInstance {
|
export interface ServerInstance {
|
||||||
wss: WebSocketServer | null;
|
wss: WebSocketServer | null;
|
||||||
clients: Set<WSClient>;
|
clients: Set<WSClient>;
|
||||||
pingIntervalId?: NodeJS.Timeout;
|
pingIntervalId?: NodeJS.Timeout;
|
||||||
timeouts: Map<WebSocket, NodeJS.Timeout>;
|
timeouts: Map<WebSocket, NodeJS.Timeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSClient extends WebSocket {
|
export interface WSClient extends WebSocket {
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
type?: WebSocketType;
|
type?: WebSocketType;
|
||||||
userId?: string
|
userId?: string;
|
||||||
origin?: string
|
origin?: string;
|
||||||
roomId?: string
|
roomId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,4 +8,4 @@ import { CollaborationModule } from './collaboration/collaboration.module';
|
||||||
providers: [WebSocketService],
|
providers: [WebSocketService],
|
||||||
exports: [WebSocketService],
|
exports: [WebSocketService],
|
||||||
})
|
})
|
||||||
export class WebSocketModule { }
|
export class WebSocketModule {}
|
||||||
|
|
|
@ -1,61 +1,61 @@
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Server } from "http";
|
import { Server } from 'http';
|
||||||
import { WSClient } from "./types";
|
import { WSClient } from './types';
|
||||||
import { RealtimeServer } from "./realtime/realtime.server";
|
import { RealtimeServer } from './realtime/realtime.server';
|
||||||
import { YjsServer } from "./collaboration/yjs.server";
|
import { YjsServer } from './collaboration/yjs.server';
|
||||||
import { BaseWebSocketServer } from "./base/base-websocket-server";
|
import { BaseWebSocketServer } from './base/base-websocket-server';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebSocketService {
|
export class WebSocketService {
|
||||||
private readonly logger = new Logger(WebSocketService.name);
|
private readonly logger = new Logger(WebSocketService.name);
|
||||||
private readonly servers: BaseWebSocketServer[] = [];
|
private readonly servers: BaseWebSocketServer[] = [];
|
||||||
constructor(
|
constructor(
|
||||||
private realTimeServer: RealtimeServer,
|
private realTimeServer: RealtimeServer,
|
||||||
private yjsServer: YjsServer
|
private yjsServer: YjsServer,
|
||||||
) {
|
) {
|
||||||
this.servers.push(this.realTimeServer)
|
this.servers.push(this.realTimeServer);
|
||||||
this.servers.push(this.yjsServer)
|
this.servers.push(this.yjsServer);
|
||||||
|
}
|
||||||
|
public async initialize(httpServer: Server): Promise<void> {
|
||||||
|
try {
|
||||||
|
await Promise.all(this.servers.map((server) => server.start()));
|
||||||
|
this.setupUpgradeHandler(httpServer);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to initialize:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
public async initialize(httpServer: Server): Promise<void> {
|
}
|
||||||
try {
|
private setupUpgradeHandler(httpServer: Server): void {
|
||||||
await Promise.all(this.servers.map(server => server.start()));
|
if (httpServer.listeners('upgrade').length) return;
|
||||||
this.setupUpgradeHandler(httpServer);
|
httpServer.on('upgrade', async (request, socket, head) => {
|
||||||
} catch (error) {
|
try {
|
||||||
this.logger.error('Failed to initialize:', error);
|
const url = new URL(request.url!, `http://${request.headers.host}`);
|
||||||
throw error;
|
const pathname = url.pathname;
|
||||||
}
|
|
||||||
}
|
|
||||||
private setupUpgradeHandler(httpServer: Server): void {
|
|
||||||
if (httpServer.listeners('upgrade').length) return;
|
|
||||||
httpServer.on('upgrade', async (request, socket, head) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(request.url!, `http://${request.headers.host}`);
|
|
||||||
const pathname = url.pathname;
|
|
||||||
|
|
||||||
// 从URL查询参数中获取roomId和token
|
// 从URL查询参数中获取roomId和token
|
||||||
const urlParams = new URLSearchParams(url.search);
|
const urlParams = new URLSearchParams(url.search);
|
||||||
const roomId = urlParams.get('roomId');
|
const roomId = urlParams.get('roomId');
|
||||||
const userId = urlParams.get('userId');
|
const userId = urlParams.get('userId');
|
||||||
const server = this.servers.find(server => {
|
const server = this.servers.find((server) => {
|
||||||
const serverPathClean = server.serverPath.replace(/\/$/, '');
|
const serverPathClean = server.serverPath.replace(/\/$/, '');
|
||||||
const pathnameClean = pathname.replace(/\/$/, '');
|
const pathnameClean = pathname.replace(/\/$/, '');
|
||||||
return serverPathClean === pathnameClean;
|
return serverPathClean === pathnameClean;
|
||||||
});
|
|
||||||
|
|
||||||
if (!server || !server.wss) {
|
|
||||||
return socket.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
server.wss!.handleUpgrade(request, socket, head, (ws: WSClient) => {
|
|
||||||
ws.userId = userId;
|
|
||||||
ws.origin = request.url
|
|
||||||
ws.roomId = roomId
|
|
||||||
server.wss!.emit('connection', ws, request);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Upgrade error:', error);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
if (!server || !server.wss) {
|
||||||
|
return socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
server.wss!.handleUpgrade(request, socket, head, (ws: WSClient) => {
|
||||||
|
ws.userId = userId;
|
||||||
|
ws.origin = request.url;
|
||||||
|
ws.roomId = roomId;
|
||||||
|
server.wss!.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Upgrade error:', error);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ export class GenDevService {
|
||||||
private readonly departmentService: DepartmentService,
|
private readonly departmentService: DepartmentService,
|
||||||
private readonly staffService: StaffService,
|
private readonly staffService: StaffService,
|
||||||
private readonly termService: TermService,
|
private readonly termService: TermService,
|
||||||
) { }
|
) {}
|
||||||
async genDataEvent() {
|
async genDataEvent() {
|
||||||
EventBus.emit('genDataEvent', { type: 'start' });
|
EventBus.emit('genDataEvent', { type: 'start' });
|
||||||
try {
|
try {
|
||||||
|
@ -87,16 +87,57 @@ export class GenDevService {
|
||||||
|
|
||||||
// 定义网系类别
|
// 定义网系类别
|
||||||
const systemTypes = [
|
const systemTypes = [
|
||||||
{ name: '文印系统', children: ['电源故障', '主板故障', '内存故障', '硬盘故障', '显示器故障', '键盘故障', '鼠标故障'] },
|
{
|
||||||
{ name: '内网系统', children: ['系统崩溃', '应用程序错误', '病毒感染', '驱动问题', '系统更新失败'] },
|
name: '文印系统',
|
||||||
{ name: 'Windows系统', children: ['系统响应慢', '资源占用过高', '过热', '电池寿命短', '存储空间不足'] },
|
children: [
|
||||||
{ name: 'Linux系统', children: ['未知错误', '用户操作错误', '环境因素', '设备老化'] },
|
'电源故障',
|
||||||
{ name: '移动设备系统', children: ['参数设置错误', '配置文件损坏', '兼容性问题', '初始化失败'] },
|
'主板故障',
|
||||||
|
'内存故障',
|
||||||
|
'硬盘故障',
|
||||||
|
'显示器故障',
|
||||||
|
'键盘故障',
|
||||||
|
'鼠标故障',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '内网系统',
|
||||||
|
children: [
|
||||||
|
'系统崩溃',
|
||||||
|
'应用程序错误',
|
||||||
|
'病毒感染',
|
||||||
|
'驱动问题',
|
||||||
|
'系统更新失败',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Windows系统',
|
||||||
|
children: [
|
||||||
|
'系统响应慢',
|
||||||
|
'资源占用过高',
|
||||||
|
'过热',
|
||||||
|
'电池寿命短',
|
||||||
|
'存储空间不足',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Linux系统',
|
||||||
|
children: ['未知错误', '用户操作错误', '环境因素', '设备老化'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '移动设备系统',
|
||||||
|
children: ['参数设置错误', '配置文件损坏', '兼容性问题', '初始化失败'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 定义安防设备的子类型
|
// 定义安防设备的子类型
|
||||||
const securityDevices = {
|
const securityDevices = {
|
||||||
未知错误: ['未授权访问', '数据泄露', '密码重置', '权限异常', '安全策略冲突'] ,
|
未知错误: [
|
||||||
|
'未授权访问',
|
||||||
|
'数据泄露',
|
||||||
|
'密码重置',
|
||||||
|
'权限异常',
|
||||||
|
'安全策略冲突',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建网系类别及其关联的设备类型
|
// 创建网系类别及其关联的设备类型
|
||||||
|
|
|
@ -4,6 +4,6 @@ import { ReminderService } from './reminder.service';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
providers: [ReminderService],
|
providers: [ReminderService],
|
||||||
exports: [ReminderService]
|
exports: [ReminderService],
|
||||||
})
|
})
|
||||||
export class ReminderModule { }
|
export class ReminderModule {}
|
||||||
|
|
|
@ -8,73 +8,70 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提醒服务类
|
* 提醒服务类
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReminderService {
|
export class ReminderService {
|
||||||
/**
|
/**
|
||||||
* 日志记录器实例
|
* 日志记录器实例
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private readonly logger = new Logger(ReminderService.name);
|
private readonly logger = new Logger(ReminderService.name);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造函数
|
* 构造函数
|
||||||
* @param messageService 消息服务实例
|
* @param messageService 消息服务实例
|
||||||
*/
|
*/
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成提醒时间点
|
* 生成提醒时间点
|
||||||
* @param totalDays 总天数
|
* @param totalDays 总天数
|
||||||
* @returns 提醒时间点数组
|
* @returns 提醒时间点数组
|
||||||
*/
|
*/
|
||||||
generateReminderTimes(totalDays: number): number[] {
|
generateReminderTimes(totalDays: number): number[] {
|
||||||
// 如果总天数小于3天则不需要提醒
|
// 如果总天数小于3天则不需要提醒
|
||||||
if (totalDays < 3) return [];
|
if (totalDays < 3) return [];
|
||||||
// 使用Set存储提醒时间点,避免重复
|
// 使用Set存储提醒时间点,避免重复
|
||||||
const reminders: Set<number> = new Set();
|
const reminders: Set<number> = new Set();
|
||||||
// 按照2的幂次方划分时间点
|
// 按照2的幂次方划分时间点
|
||||||
for (let i = 1; i <= totalDays / 2; i++) {
|
for (let i = 1; i <= totalDays / 2; i++) {
|
||||||
reminders.add(Math.ceil(totalDays / Math.pow(2, i)));
|
reminders.add(Math.ceil(totalDays / Math.pow(2, i)));
|
||||||
}
|
|
||||||
// 将Set转为数组并升序排序
|
|
||||||
return Array.from(reminders).sort((a, b) => a - b);
|
|
||||||
}
|
}
|
||||||
|
// 将Set转为数组并升序排序
|
||||||
|
return Array.from(reminders).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否需要发送提醒
|
* 判断是否需要发送提醒
|
||||||
* @param createdAt 创建时间
|
* @param createdAt 创建时间
|
||||||
* @param deadline 截止时间
|
* @param deadline 截止时间
|
||||||
* @returns 是否需要提醒及剩余天数
|
* @returns 是否需要提醒及剩余天数
|
||||||
*/
|
*/
|
||||||
shouldSendReminder(createdAt: Date, deadline: Date) {
|
shouldSendReminder(createdAt: Date, deadline: Date) {
|
||||||
// 获取当前时间
|
// 获取当前时间
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const end = dayjs(deadline);
|
const end = dayjs(deadline);
|
||||||
// 计算总时间和剩余时间(天)
|
// 计算总时间和剩余时间(天)
|
||||||
const totalTimeDays = end.diff(createdAt, 'day');
|
const totalTimeDays = end.diff(createdAt, 'day');
|
||||||
const timeLeftDays = end.diff(now, 'day');
|
const timeLeftDays = end.diff(now, 'day');
|
||||||
|
|
||||||
if (totalTimeDays > 1) {
|
if (totalTimeDays > 1) {
|
||||||
// 获取提醒时间点
|
// 获取提醒时间点
|
||||||
const reminderTimes = this.generateReminderTimes(totalTimeDays);
|
const reminderTimes = this.generateReminderTimes(totalTimeDays);
|
||||||
// 如果剩余时间在提醒时间点内,则需要提醒
|
// 如果剩余时间在提醒时间点内,则需要提醒
|
||||||
if (reminderTimes.includes(timeLeftDays)) {
|
if (reminderTimes.includes(timeLeftDays)) {
|
||||||
return { shouldSend: true, timeLeft: timeLeftDays };
|
return { shouldSend: true, timeLeft: timeLeftDays };
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return { shouldSend: false, timeLeft: timeLeftDays };
|
|
||||||
}
|
}
|
||||||
|
return { shouldSend: false, timeLeft: timeLeftDays };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送截止日期提醒
|
* 发送截止日期提醒
|
||||||
*/
|
*/
|
||||||
async remindDeadline() {
|
async remindDeadline() {
|
||||||
this.logger.log('开始检查截止日期以发送提醒。');
|
this.logger.log('开始检查截止日期以发送提醒。');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
import { InitModule } from '@server/tasks/init/init.module';
|
import { InitModule } from '@server/tasks/init/init.module';
|
||||||
import { ReminderModule } from "@server/tasks/reminder/reminder.module"
|
import { ReminderModule } from '@server/tasks/reminder/reminder.module';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InitModule, ReminderModule],
|
imports: [InitModule, ReminderModule],
|
||||||
providers: [TasksService]
|
providers: [TasksService],
|
||||||
})
|
})
|
||||||
export class TasksModule { }
|
export class TasksModule {}
|
||||||
|
|
|
@ -6,41 +6,47 @@ import { CronJob } from 'cron';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TasksService implements OnModuleInit {
|
export class TasksService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TasksService.name);
|
private readonly logger = new Logger(TasksService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly schedulerRegistry: SchedulerRegistry,
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
private readonly initService: InitService,
|
private readonly initService: InitService,
|
||||||
private readonly reminderService: ReminderService
|
private readonly reminderService: ReminderService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Main node launch');
|
this.logger.log('Main node launch');
|
||||||
await this.initService.init();
|
await this.initService.init();
|
||||||
this.logger.log('Initialization successful');
|
this.logger.log('Initialization successful');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cronExpression = process.env.DEADLINE_CRON;
|
||||||
|
if (!cronExpression) {
|
||||||
|
throw new Error('DEADLINE_CRON environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemindJob = new CronJob(cronExpression, async () => {
|
||||||
try {
|
try {
|
||||||
const cronExpression = process.env.DEADLINE_CRON;
|
await this.reminderService.remindDeadline();
|
||||||
if (!cronExpression) {
|
this.logger.log('Reminder successfully processed');
|
||||||
throw new Error('DEADLINE_CRON environment variable is not set');
|
} catch (reminderErr) {
|
||||||
}
|
this.logger.error(
|
||||||
|
'Error occurred while processing reminder',
|
||||||
const handleRemindJob = new CronJob(cronExpression, async () => {
|
reminderErr,
|
||||||
try {
|
);
|
||||||
await this.reminderService.remindDeadline();
|
|
||||||
this.logger.log('Reminder successfully processed');
|
|
||||||
} catch (reminderErr) {
|
|
||||||
this.logger.error('Error occurred while processing reminder', reminderErr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.schedulerRegistry.addCronJob('remindDeadline', handleRemindJob as any);
|
|
||||||
this.logger.log('Start remind cron job');
|
|
||||||
handleRemindJob.start();
|
|
||||||
} catch (cronJobErr) {
|
|
||||||
this.logger.error('Failed to initialize cron job', cronJobErr);
|
|
||||||
// Optionally rethrow the error if you want to halt further execution
|
|
||||||
// throw cronJobErr;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.schedulerRegistry.addCronJob(
|
||||||
|
'remindDeadline',
|
||||||
|
handleRemindJob as any,
|
||||||
|
);
|
||||||
|
this.logger.log('Start remind cron job');
|
||||||
|
handleRemindJob.start();
|
||||||
|
} catch (cronJobErr) {
|
||||||
|
this.logger.error('Failed to initialize cron job', cronJobErr);
|
||||||
|
// Optionally rethrow the error if you want to halt further execution
|
||||||
|
// throw cronJobErr;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ export class UploadController {
|
||||||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/*')
|
@Get('/*')
|
||||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
|
@ -66,5 +66,4 @@ export class UploadController {
|
||||||
async handleUpload(@Req() req: Request, @Res() res: Response) {
|
async handleUpload(@Req() req: Request, @Res() res: Response) {
|
||||||
return this.tusService.handleTus(req, res);
|
return this.tusService.handleTus(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,6 @@ import { MinioService } from './minio.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [MinioService],
|
providers: [MinioService],
|
||||||
exports: [MinioService]
|
exports: [MinioService],
|
||||||
})
|
})
|
||||||
export class MinioModule {}
|
export class MinioModule {}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { redis } from "./redis.service";
|
import { redis } from './redis.service';
|
||||||
|
|
||||||
export async function deleteByPattern(pattern: string) {
|
export async function deleteByPattern(pattern: string) {
|
||||||
try {
|
try {
|
||||||
const keys = await redis.keys(pattern);
|
const keys = await redis.keys(pattern);
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await redis.del(keys);
|
await redis.del(keys);
|
||||||
// this.logger.log(`Deleted ${keys.length} keys matching pattern ${pattern}`);
|
// this.logger.log(`Deleted ${keys.length} keys matching pattern ${pattern}`);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete keys by pattern ${pattern}:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete keys by pattern ${pattern}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,148 +1,149 @@
|
||||||
import { createReadStream } from "fs";
|
import { createReadStream } from 'fs';
|
||||||
import { createInterface } from "readline";
|
import { createInterface } from 'readline';
|
||||||
|
|
||||||
import { db } from '@nice/common';
|
import { db } from '@nice/common';
|
||||||
import * as tus from "tus-js-client";
|
import * as tus from 'tus-js-client';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
export function truncateStringByByte(str, maxBytes) {
|
export function truncateStringByByte(str, maxBytes) {
|
||||||
let byteCount = 0;
|
let byteCount = 0;
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while (index < str.length && byteCount + new TextEncoder().encode(str[index]).length <= maxBytes) {
|
while (
|
||||||
byteCount += new TextEncoder().encode(str[index]).length;
|
index < str.length &&
|
||||||
index++;
|
byteCount + new TextEncoder().encode(str[index]).length <= maxBytes
|
||||||
}
|
) {
|
||||||
return str.substring(0, index) + (index < str.length ? "..." : "");
|
byteCount += new TextEncoder().encode(str[index]).length;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return str.substring(0, index) + (index < str.length ? '...' : '');
|
||||||
}
|
}
|
||||||
export async function loadPoliciesFromCSV(filePath: string) {
|
export async function loadPoliciesFromCSV(filePath: string) {
|
||||||
const policies = {
|
const policies = {
|
||||||
p: [],
|
p: [],
|
||||||
g: []
|
g: [],
|
||||||
};
|
};
|
||||||
const stream = createReadStream(filePath);
|
const stream = createReadStream(filePath);
|
||||||
const rl = createInterface({
|
const rl = createInterface({
|
||||||
input: stream,
|
input: stream,
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Updated regex to handle commas inside parentheses as part of a single field
|
// Updated regex to handle commas inside parentheses as part of a single field
|
||||||
const regex = /(?:\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)|"(?:\\"|[^"])*"|[^,"()\s]+)(?=\s*,|\s*$)/g;
|
const regex =
|
||||||
|
/(?:\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)|"(?:\\"|[^"])*"|[^,"()\s]+)(?=\s*,|\s*$)/g;
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
// Ignore empty lines and comments
|
// Ignore empty lines and comments
|
||||||
if (line.trim() && !line.startsWith("#")) {
|
if (line.trim() && !line.startsWith('#')) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let match;
|
let match;
|
||||||
while ((match = regex.exec(line)) !== null) {
|
while ((match = regex.exec(line)) !== null) {
|
||||||
// Remove quotes if present and trim whitespace
|
// Remove quotes if present and trim whitespace
|
||||||
parts.push(match[0].replace(/^"|"$/g, '').trim());
|
parts.push(match[0].replace(/^"|"$/g, '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check policy type (p or g)
|
// Check policy type (p or g)
|
||||||
const ptype = parts[0];
|
const ptype = parts[0];
|
||||||
const rule = parts.slice(1);
|
const rule = parts.slice(1);
|
||||||
|
|
||||||
if (ptype === 'p' || ptype === 'g') {
|
if (ptype === 'p' || ptype === 'g') {
|
||||||
policies[ptype].push(rule);
|
policies[ptype].push(rule);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Unknown policy type '${ptype}' in policy: ${line}`);
|
console.warn(`Unknown policy type '${ptype}' in policy: ${line}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return policies;
|
return policies;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadFile(blob: any, fileName: string) {
|
export function uploadFile(blob: any, fileName: string) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const upload = new tus.Upload(blob, {
|
const upload = new tus.Upload(blob, {
|
||||||
endpoint: `${process.env.TUS_URL}/files/`,
|
endpoint: `${process.env.TUS_URL}/files/`,
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
retryDelays: [0, 1000, 3000, 5000],
|
||||||
metadata: {
|
metadata: {
|
||||||
filename: fileName,
|
filename: fileName,
|
||||||
filetype:
|
filetype:
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed because: " + error);
|
console.error('Failed because: ' + error);
|
||||||
reject(error); // 错误时,我们要拒绝 promise
|
reject(error); // 错误时,我们要拒绝 promise
|
||||||
},
|
},
|
||||||
onProgress: (bytesUploaded, bytesTotal) => {
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
||||||
// console.log(bytesUploaded, bytesTotal, `${percentage}%`);
|
// console.log(bytesUploaded, bytesTotal, `${percentage}%`);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// console.log('Upload finished:', upload.url);
|
// console.log('Upload finished:', upload.url);
|
||||||
resolve(upload.url); // 成功后,我们解析 promise,并返回上传的 URL
|
resolve(upload.url); // 成功后,我们解析 promise,并返回上传的 URL
|
||||||
},
|
},
|
||||||
});
|
|
||||||
upload.start();
|
|
||||||
});
|
});
|
||||||
|
upload.start();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TreeNode {
|
class TreeNode {
|
||||||
value: string;
|
value: string;
|
||||||
children: TreeNode[];
|
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)
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function buildTree(data: string[][]): TreeNode {
|
function buildTree(data: string[][]): TreeNode {
|
||||||
const root = new TreeNode('root');
|
const root = new TreeNode('root');
|
||||||
try {
|
try {
|
||||||
for (const path of data) {
|
for (const path of data) {
|
||||||
let currentNode = root;
|
let currentNode = root;
|
||||||
for (const value of path) {
|
for (const value of path) {
|
||||||
currentNode = currentNode.addChild(value);
|
currentNode = currentNode.addChild(value);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
return root;
|
||||||
console.error(error)
|
} catch (error) {
|
||||||
}
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
export function printTree(node: TreeNode, level: number = 0): void {
|
export function printTree(node: TreeNode, level: number = 0): void {
|
||||||
const indent = ' '.repeat(level);
|
const indent = ' '.repeat(level);
|
||||||
// console.log(`${indent}${node.value}`);
|
// console.log(`${indent}${node.value}`);
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
printTree(child, level + 1);
|
printTree(child, level + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function generateTreeFromFile(file: Buffer): Promise<TreeNode> {
|
export async function generateTreeFromFile(file: Buffer): Promise<TreeNode> {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file);
|
await workbook.xlsx.load(file);
|
||||||
const worksheet = workbook.getWorksheet(1);
|
const worksheet = workbook.getWorksheet(1);
|
||||||
|
|
||||||
const data: string[][] = [];
|
const data: string[][] = [];
|
||||||
|
|
||||||
worksheet.eachRow((row, rowNumber) => {
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
if (rowNumber > 1) { // Skip header row if any
|
if (rowNumber > 1) {
|
||||||
const rowData: string[] = (row.values as string[]).slice(2).map(cell => (cell || '').toString());
|
// Skip header row if any
|
||||||
data.push(rowData.map(value => value.trim()));
|
const rowData: string[] = (row.values as string[])
|
||||||
}
|
.slice(2)
|
||||||
});
|
.map((cell) => (cell || '').toString());
|
||||||
// Fill forward values
|
data.push(rowData.map((value) => value.trim()));
|
||||||
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 buildTree(data);
|
});
|
||||||
}
|
// 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 buildTree(data);
|
||||||
|
}
|
||||||
|
|
|
@ -2,17 +2,17 @@ import { PipeTransform, BadRequestException } from '@nestjs/common';
|
||||||
import { ZodSchema } from 'zod';
|
import { ZodSchema } from 'zod';
|
||||||
|
|
||||||
export class ZodValidationPipe implements PipeTransform {
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
constructor(private schema: ZodSchema) { }
|
constructor(private schema: ZodSchema) {}
|
||||||
|
|
||||||
transform(value: unknown) {
|
transform(value: unknown) {
|
||||||
try {
|
try {
|
||||||
const result = this.schema.parse(value);
|
const result = this.schema.parse(value);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new BadRequestException('Validation failed', {
|
throw new BadRequestException('Validation failed', {
|
||||||
cause: error,
|
cause: error,
|
||||||
description: error.errors
|
description: error.errors,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
VITE_APP_FILE_PORT: "$FILE_PORT",
|
VITE_APP_FILE_PORT: "$FILE_PORT",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<title>$APP_NAME</title>
|
<title>故障检索</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
@ -1,10 +1,390 @@
|
||||||
const DashboardPage = () => {
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Card, Row, Col, DatePicker, Select, Spin } from 'antd';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
|
||||||
return (
|
const { RangePicker } = DatePicker;
|
||||||
<div className="min-h-screen">
|
const { Option } = Select;
|
||||||
<h1>Dashboard</h1>
|
|
||||||
</div>
|
const DashboardPage = () => {
|
||||||
);
|
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
|
||||||
|
dayjs().subtract(30, 'days'),
|
||||||
|
dayjs()
|
||||||
|
]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
|
// 使用useRef跟踪是否已执行过刷新
|
||||||
|
const hasRefetched = useRef(false);
|
||||||
|
|
||||||
|
// 获取网系类别数据
|
||||||
|
const { data: systemTypeTerms, isLoading: loadingTypes, refetch: refetchSypes } = api.term.findMany.useQuery({
|
||||||
|
where: {
|
||||||
|
taxonomy: { slug: "system_type" },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取设备故障数据
|
||||||
|
const { data: devices, isLoading: loadingDevices, refetch: refetchDevices } = api.device.findMany.useQuery({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: {
|
||||||
|
// 将开始日期设置为当天的开始时间 00:00:00
|
||||||
|
gte: dateRange[0].startOf('day').toISOString(),
|
||||||
|
// 将结束日期设置为当天的结束时间 23:59:59
|
||||||
|
lte: dateRange[1].endOf('day').toISOString(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
department: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
// 只在数据加载完成后执行一次刷新
|
||||||
|
if (!loadingTypes && !loadingDevices && !hasRefetched.current) {
|
||||||
|
// 标记为已执行
|
||||||
|
hasRefetched.current = true
|
||||||
|
// 刷新数据
|
||||||
|
refetchSypes();
|
||||||
|
refetchDevices();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadingTypes, loadingDevices]);
|
||||||
|
|
||||||
|
|
||||||
|
// 处理日期范围变化
|
||||||
|
const handleDateRangeChange = (dates, dateStrings) => {
|
||||||
|
if (dates) {
|
||||||
|
setDateRange([dates[0], dates[1]]);
|
||||||
|
} else {
|
||||||
|
setDateRange([dayjs().subtract(30, 'days'), dayjs()]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 准备各个网系故障情况数据(按时间段)
|
||||||
|
const prepareSystemFaultsByTimeData = () => {
|
||||||
|
if (!devices || !systemTypeTerms) return { xAxis: [], series: [] };
|
||||||
|
|
||||||
|
// 获取选择的日期范围
|
||||||
|
const startDate = dateRange[0];
|
||||||
|
const endDate = dateRange[1];
|
||||||
|
const diffDays = endDate.diff(startDate, 'day');
|
||||||
|
|
||||||
|
// 根据时间跨度选择合适的间隔
|
||||||
|
const intervals = [];
|
||||||
|
let format = '';
|
||||||
|
|
||||||
|
if (diffDays <= 14) {
|
||||||
|
// 小于等于两周,按天展示
|
||||||
|
format = 'MM-DD';
|
||||||
|
for (let i = 0; i <= diffDays; i++) {
|
||||||
|
intervals.push(startDate.add(i, 'day'));
|
||||||
|
}
|
||||||
|
} else if (diffDays <= 90) {
|
||||||
|
// 小于等于3个月,按周展示
|
||||||
|
format = 'MM-DD';
|
||||||
|
const weeks = Math.ceil(diffDays / 7);
|
||||||
|
for (let i = 0; i < weeks; i++) {
|
||||||
|
const weekStart = startDate.add(i * 7, 'day');
|
||||||
|
intervals.push(weekStart);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 大于3个月,按月展示
|
||||||
|
format = 'YYYY-MM';
|
||||||
|
const months = Math.ceil(diffDays / 30);
|
||||||
|
for (let i = 0; i < months; i++) {
|
||||||
|
const monthStart = startDate.add(i, 'month').startOf('month');
|
||||||
|
intervals.push(monthStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间段显示
|
||||||
|
const timeLabels = intervals.map(date => {
|
||||||
|
if (diffDays <= 14) {
|
||||||
|
return date.format(format);
|
||||||
|
} else if (diffDays <= 90) {
|
||||||
|
return `${date.format(format)}至${date.add(6, 'day').format(format)}`;
|
||||||
|
} else {
|
||||||
|
return date.format(format);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计每个网系在每个时间段的故障数
|
||||||
|
const systemNames = systemTypeTerms.map(type => type.name);
|
||||||
|
const systemIdMap = {};
|
||||||
|
systemTypeTerms.forEach(type => {
|
||||||
|
systemIdMap[type.id] = type.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesData = systemNames.map(name => ({
|
||||||
|
name,
|
||||||
|
type: 'bar',
|
||||||
|
data: new Array(intervals.length).fill(0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
devices.forEach(device => {
|
||||||
|
const systemName = systemIdMap[device.systemType] || '未知';
|
||||||
|
const systemIndex = systemNames.indexOf(systemName);
|
||||||
|
if (systemIndex !== -1) {
|
||||||
|
const createDate = dayjs(device.createdAt);
|
||||||
|
|
||||||
|
for (let i = 0; i < intervals.length; i++) {
|
||||||
|
const nextInterval = null;
|
||||||
|
|
||||||
|
if (diffDays <= 14) {
|
||||||
|
// 按天,检查是否是同一天
|
||||||
|
if (createDate.format('YYYY-MM-DD') === intervals[i].format('YYYY-MM-DD')) {
|
||||||
|
seriesData[systemIndex].data[i]++;
|
||||||
|
}
|
||||||
|
} else if (diffDays <= 90) {
|
||||||
|
// 按周,检查是否在这周内
|
||||||
|
const weekEnd = intervals[i].add(6, 'day').endOf('day');
|
||||||
|
if (createDate.isAfter(intervals[i]) && createDate.isBefore(weekEnd)) {
|
||||||
|
seriesData[systemIndex].data[i]++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 按月,检查是否在这个月内
|
||||||
|
const monthEnd = intervals[i].endOf('month');
|
||||||
|
if (createDate.isAfter(intervals[i]) && createDate.isBefore(monthEnd)) {
|
||||||
|
seriesData[systemIndex].data[i]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
xAxis: timeLabels,
|
||||||
|
series: seriesData
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 准备各个网系故障率数据
|
||||||
|
const prepareSystemFaultRateData = () => {
|
||||||
|
if (!devices || !systemTypeTerms) return { names: [], values: [] };
|
||||||
|
|
||||||
|
const systemCounts = {};
|
||||||
|
const totalDevices = devices.length;
|
||||||
|
console.log("devices", devices.length);
|
||||||
|
|
||||||
|
// 初始化所有网系的计数
|
||||||
|
systemTypeTerms.forEach(type => {
|
||||||
|
systemCounts[type.name] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计每个网系的故障数
|
||||||
|
devices.forEach(device => {
|
||||||
|
const systemType = systemTypeTerms?.find(t => t.id === device.systemType);
|
||||||
|
if (systemType) {
|
||||||
|
systemCounts[systemType.name] = (systemCounts[systemType.name] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const names = Object.keys(systemCounts);
|
||||||
|
const values = names.map(name => ({
|
||||||
|
value: totalDevices ? ((systemCounts[name] / totalDevices) * 100).toFixed(2) : 0,
|
||||||
|
name
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { names, values };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 准备故障处置完成率数据
|
||||||
|
const prepareFaultCompletionRateData = () => {
|
||||||
|
if (!devices) return { value: 0 };
|
||||||
|
const completedFaults = devices.filter(device =>
|
||||||
|
device.deviceStatus === 'normal'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const totalFaults = devices.length;
|
||||||
|
const completionRate = totalFaults ? (completedFaults / totalFaults) * 100 : 0;
|
||||||
|
|
||||||
|
return { value: parseFloat(completionRate.toFixed(2)) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网系故障时间段分布选项
|
||||||
|
const getSystemFaultsByTimeOption = () => {
|
||||||
|
const data = prepareSystemFaultsByTimeData();
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: '各网系故障时间分布',
|
||||||
|
left: 'center'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: data.series.map(item => item.name),
|
||||||
|
bottom: 10
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '15%',
|
||||||
|
top: '15%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: data.xAxis
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '故障数量'
|
||||||
|
},
|
||||||
|
series: data.series
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 网系故障率选项
|
||||||
|
const getSystemFaultRateOption = () => {
|
||||||
|
const data = prepareSystemFaultRateData();
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: '各网系故障率',
|
||||||
|
left: 'center'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: '{a} <br/>{b}: {c}%'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
orient: 'vertical',
|
||||||
|
left: 10,
|
||||||
|
top: 'center',
|
||||||
|
data: data.names
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '故障率',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: '{b}: {c}%'
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: '15',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: data.values
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 故障处置完成率选项
|
||||||
|
const getFaultCompletionRateOption = () => {
|
||||||
|
const data = prepareFaultCompletionRateData();
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: '故障处置完成率',
|
||||||
|
left: 'center'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
formatter: '{a} <br/>{b} : {c}%'
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '完成率',
|
||||||
|
type: 'gauge',
|
||||||
|
detail: { formatter: '{value}%' },
|
||||||
|
data: [{ value: data.value, name: '完成率' }],
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 30,
|
||||||
|
color: [
|
||||||
|
[0.3, '#ff6e76'],
|
||||||
|
[0.7, '#fddd60'],
|
||||||
|
[1, '#7cffb2']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pointer: {
|
||||||
|
itemStyle: {
|
||||||
|
color: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 min-h-screen bg-gray-50">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">故障数据可视化面板</h1>
|
||||||
|
{/* <RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
className="mb-2"
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<Row className="mb-4">
|
||||||
|
<Col span={24}>
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<div className="flex justify-between items-center ">
|
||||||
|
<span className="text-lg font-medium">数据筛选</span>
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<Spin size="large" tip="数据加载中..." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||||
|
<ReactECharts
|
||||||
|
option={getFaultCompletionRateOption()}
|
||||||
|
style={{ height: '400px' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||||
|
<ReactECharts
|
||||||
|
option={getSystemFaultRateOption()}
|
||||||
|
style={{ height: '400px' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={24}>
|
||||||
|
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
|
||||||
|
<ReactECharts
|
||||||
|
option={getSystemFaultsByTimeOption()}
|
||||||
|
style={{ height: '450px' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DashboardPage;
|
export default DashboardPage;
|
|
@ -85,7 +85,7 @@ export default function DeviceModal() {
|
||||||
label="故障名称 "
|
label="故障名称 "
|
||||||
rules={[{ required: true, message: "请输入故障名称" }]}
|
rules={[{ required: true, message: "请输入故障名称" }]}
|
||||||
>
|
>
|
||||||
<Input className="rounded-lg" />
|
<Input className="rounded-lg" placeholder="请输入故障名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -98,11 +98,10 @@ export default function DeviceModal() {
|
||||||
|
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Form.Item name="deviceStatus" label="故障状态">
|
<Form.Item name="deviceStatus" label="故障状态">
|
||||||
<Select className="rounded-lg">
|
<Select className="rounded-lg" placeholder="请选择故障状态">
|
||||||
<Select.Option value="normal">正常</Select.Option>
|
<Select.Option value="normal">已修复</Select.Option>
|
||||||
<Select.Option value="maintenance">维修中</Select.Option>
|
<Select.Option value="maintenance">维修中</Select.Option>
|
||||||
<Select.Option value="broken">损坏</Select.Option>
|
<Select.Option value="broken">未修复</Select.Option>
|
||||||
<Select.Option value="idle">闲置</Select.Option>
|
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button, Checkbox, Modal, Table, Upload } from "antd";
|
import { Button, Checkbox, Modal, Table, Upload, Tag } from "antd"; // 添加 Tag 导入
|
||||||
import { ColumnsType } from "antd/es/table";
|
import { ColumnsType } from "antd/es/table";
|
||||||
import { api, useDevice, useStaff } from "@nice/client";
|
import { api, useDevice, useStaff } from "@nice/client";
|
||||||
import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||||
|
@ -51,6 +51,8 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
console.log("devices",devices);
|
||||||
|
|
||||||
const { data: systemTypeTerms, refetch: refetchSystemType } =
|
const { data: systemTypeTerms, refetch: refetchSystemType } =
|
||||||
api.term.findMany.useQuery({
|
api.term.findMany.useQuery({
|
||||||
where: {
|
where: {
|
||||||
|
@ -181,21 +183,39 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
key: "deviceStatus",
|
key: "deviceStatus",
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (status) => {
|
render: (status) => {
|
||||||
const statusMap = {
|
const statusConfig = {
|
||||||
normal: "正常",
|
normal: {
|
||||||
maintenance: "维修中",
|
text: "已修复",
|
||||||
broken: "损坏",
|
color: "success"
|
||||||
idle: "闲置"
|
},
|
||||||
|
maintenance: {
|
||||||
|
text: "维修中",
|
||||||
|
color: "processing"
|
||||||
|
},
|
||||||
|
broken: {
|
||||||
|
text: "未修复",
|
||||||
|
color: "error"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return statusMap[status] || "未知";
|
|
||||||
},
|
const config = statusConfig[status] || {
|
||||||
|
text: "未知",
|
||||||
|
color: "default"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} style={{ minWidth: '60px' , textAlign: 'center' }}>
|
||||||
|
{config.text}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "时间",
|
title: "时间",
|
||||||
dataIndex: "createdAt",
|
dataIndex: "createdAt",
|
||||||
key: "createdAt",
|
key: "createdAt",
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (text, record) => record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD') : "未知",
|
render: (text, record) => record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : "未知",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
|
@ -262,7 +282,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
'故障名称': item?.showname || "未命名故障",
|
'故障名称': item?.showname || "未命名故障",
|
||||||
'故障状态': (() => {
|
'故障状态': (() => {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
normal: "正常",
|
normal: "已修复",
|
||||||
maintenance: "维修中",
|
maintenance: "维修中",
|
||||||
broken: "损坏",
|
broken: "损坏",
|
||||||
idle: "闲置"
|
idle: "闲置"
|
||||||
|
@ -322,7 +342,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
// 确认是否有有效数据
|
// 确认是否有有效数据
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
toast.error("未找到有效数据");
|
toast.error("未找到有效数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 批量创建记录
|
// 批量创建记录
|
||||||
await batchImportRecords(records);
|
await batchImportRecords(records);
|
||||||
|
@ -366,10 +386,9 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
// 获取状态键
|
// 获取状态键
|
||||||
const getStatusKeyByValue = (value: string) => {
|
const getStatusKeyByValue = (value: string) => {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
"正常": "normal",
|
"已修复": "normal",
|
||||||
"维修中": "maintenance",
|
"维修中": "maintenance",
|
||||||
"损坏": "broken",
|
"未修复 ": "broken",
|
||||||
"闲置": "idle"
|
|
||||||
};
|
};
|
||||||
return statusMap[value] || "normal";
|
return statusMap[value] || "normal";
|
||||||
};
|
};
|
||||||
|
@ -388,15 +407,12 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
toast.error("没有找到有效的记录数据");
|
toast.error("没有找到有效的记录数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置批处理大小
|
// 设置批处理大小
|
||||||
const batchSize = 5;
|
const batchSize = 5;
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let totalProcessed = 0;
|
let totalProcessed = 0;
|
||||||
|
|
||||||
// 显示进度提示
|
// 显示进度提示
|
||||||
const loadingToast = toast.loading(`正在导入数据...`);
|
const loadingToast = toast.loading(`正在导入数据...`);
|
||||||
|
|
||||||
// 分批处理数据
|
// 分批处理数据
|
||||||
for (let i = 0; i < validRecords.length; i += batchSize) {
|
for (let i = 0; i < validRecords.length; i += batchSize) {
|
||||||
const batch = validRecords.slice(i, i + batchSize);
|
const batch = validRecords.slice(i, i + batchSize);
|
||||||
|
@ -444,7 +460,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
'故障类型': deviceTypeTerms?.[0]?.name || '故障类型1',
|
'故障类型': deviceTypeTerms?.[0]?.name || '故障类型1',
|
||||||
'单位': '单位名称',
|
'单位': '单位名称',
|
||||||
'故障名称': '示例故障名称',
|
'故障名称': '示例故障名称',
|
||||||
'故障状态': '正常', // 可选值: 正常、维修中、损坏、闲置
|
'故障状态': '未修复', // 可选值:已修复, 维修中, 未修复
|
||||||
'描述': '这是一个示例描述'
|
'描述': '这是一个示例描述'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@ -479,7 +495,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
indeterminate={selectedRowKeys?.length > 0 && selectedRowKeys?.length < (devices?.length || 0)}
|
indeterminate={selectedRowKeys?.length > 0 && selectedRowKeys?.length < (devices?.length || 0)}
|
||||||
checked={(devices?.length || 0) > 0 && selectedRowKeys?.length === (devices?.length || 0)}
|
checked={(devices?.length || 0) > 0 && selectedRowKeys?.length === (devices?.length || 0)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.target.checked;
|
const checked = e.target.checked;
|
||||||
const newSelectedRowKeys = checked ? (devices || []).map(item => item.id) : [];
|
const newSelectedRowKeys = checked ? (devices || []).map(item => item.id) : [];
|
||||||
|
@ -490,7 +506,10 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
preserveSelectedRowKeys: true // 这个属性保证翻页时选中状态不丢失
|
preserveSelectedRowKeys: true // 这个属性保证翻页时选中状态不丢失
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const TableHeader = () => (
|
const TableHeader = () => (
|
||||||
<div className="w-full flex justify-between mb-2">
|
<div className="w-full flex justify-between mb-2">
|
||||||
<span>
|
<span>
|
||||||
|
@ -562,6 +581,10 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
pageSizeOptions: ["10", "20", "30"],
|
pageSizeOptions: ["10", "20", "30"],
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
responsive: true,
|
||||||
|
showTotal: (total, range) => `共${total} 条数据`,
|
||||||
|
showQuickJumper: true,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
header: {
|
header: {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
|
||||||
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
|
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
|
||||||
import DeviceTypeSelect from "@web/src/app/main/devicepage/select/Device-select";
|
import DeviceTypeSelect from "@web/src/app/main/devicepage/select/Device-select";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import FixTypeSelect from "./select/Fix-select";
|
||||||
// 添加筛选条件类型
|
// 添加筛选条件类型
|
||||||
type SearchCondition = {
|
type SearchCondition = {
|
||||||
deletedAt: null;
|
deletedAt: null;
|
||||||
|
@ -58,6 +59,7 @@ export default function DeviceMessage() {
|
||||||
const [location, setLocation] = useState<string>("");
|
const [location, setLocation] = useState<string>("");
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [selectedSystemTypeId, setSelectedSystemTypeId] = useState<string>("");
|
const [selectedSystemTypeId, setSelectedSystemTypeId] = useState<string>("");
|
||||||
|
const [selectedFixType, setSelectedFixType] = useState<string | null>(null);
|
||||||
|
|
||||||
// 创建ref以访问DeviceTable内部方法
|
// 创建ref以访问DeviceTable内部方法
|
||||||
const tableRef = useRef(null);
|
const tableRef = useRef(null);
|
||||||
|
@ -78,6 +80,7 @@ export default function DeviceMessage() {
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
...(selectedSystem && { systemType: selectedSystem }),
|
...(selectedSystem && { systemType: selectedSystem }),
|
||||||
...(selectedDeviceType && { deviceType: selectedDeviceType }),
|
...(selectedDeviceType && { deviceType: selectedDeviceType }),
|
||||||
|
...(selectedFixType && { deviceStatus: selectedFixType }),
|
||||||
...(selectedDept && { deptId: selectedDept }),
|
...(selectedDept && { deptId: selectedDept }),
|
||||||
...(time && {
|
...(time && {
|
||||||
createdAt: {
|
createdAt: {
|
||||||
|
@ -120,7 +123,7 @@ export default function DeviceMessage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理选择变更的回调
|
// 处理选择变更的回调
|
||||||
const handleSelectedChange = (keys: React.Key[], data: any[]) => {
|
const handleSelectedChange = (keys: React.Key[], data: any[]) => {
|
||||||
console.log("选中状态变化:", keys.length);
|
console.log("选中状态变化:", keys.length);
|
||||||
setSelectedKeys(keys);
|
setSelectedKeys(keys);
|
||||||
setSelectedData(data);
|
setSelectedData(data);
|
||||||
|
@ -144,7 +147,7 @@ export default function DeviceMessage() {
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h1 className="text-xl font-normal">故障收录检索</h1>
|
<h1 className="text-xl font-normal">故障收录检索</h1>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleNew}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleNew}>
|
||||||
新建
|
新建
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -165,6 +168,14 @@ export default function DeviceMessage() {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
systemTypeId={selectedSystemTypeId}
|
systemTypeId={selectedSystemTypeId}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<FixTypeSelect
|
||||||
|
value={selectedFixType}
|
||||||
|
onChange={setSelectedFixType}
|
||||||
|
placeholder="选择故障状态"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
<DepartmentSelect
|
<DepartmentSelect
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
// apps/web/src/components/models/term/system-type-select.tsx
|
||||||
|
import { Select } from "antd";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface FixTypeSelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FixTypeSelect({ value,onChange,placeholder = "选择故障状态",disabled = false,className,style,}: FixTypeSelectProps) {
|
||||||
|
// 故障状态是固定的,直接定义选项
|
||||||
|
const options = [
|
||||||
|
{ label: "已修复", value: "normal" },
|
||||||
|
{ label: "维修中", value: "maintenance" },
|
||||||
|
{ label: "未修复", value: "broken" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label?.toString() || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -29,12 +29,9 @@ function getItem(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const items = [
|
const items = [
|
||||||
getItem("首页", "/dashboard", <HomeOutlined />, null, null),
|
getItem("数据看板", "/dashboard", <HomeOutlined />, null, null),
|
||||||
getItem("故障收录检索", "/device", <BookOutlined />, null, null),
|
getItem("故障收录检索", "/device", <BookOutlined />, null, null),
|
||||||
getItem(
|
getItem("系统设置","/admin",<SettingOutlined />,
|
||||||
"系统设置",
|
|
||||||
"/admin",
|
|
||||||
<SettingOutlined />,
|
|
||||||
[
|
[
|
||||||
getItem("基本设置", "/admin/base-setting", <FormOutlined />, null, null),
|
getItem("基本设置", "/admin/base-setting", <FormOutlined />, null, null),
|
||||||
getItem("用户管理", "/admin/user", <UserOutlined />, null, null),
|
getItem("用户管理", "/admin/user", <UserOutlined />, null, null),
|
||||||
|
@ -42,7 +39,7 @@ const items = [
|
||||||
getItem("角色管理", "/admin/role", <KeyOutlined />, null, null),
|
getItem("角色管理", "/admin/role", <KeyOutlined />, null, null),
|
||||||
// getItem("考核标准管理", "/admin/assessment-standard", null, null, null),
|
// getItem("考核标准管理", "/admin/assessment-standard", null, null, null),
|
||||||
],
|
],
|
||||||
null
|
null
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ server {
|
||||||
# 监听80端口
|
# 监听80端口
|
||||||
listen 80;
|
listen 80;
|
||||||
# 服务器域名/IP地址,使用环境变量
|
# 服务器域名/IP地址,使用环境变量
|
||||||
server_name 192.168.119.194;
|
server_name 192.168.21.194;
|
||||||
|
|
||||||
# 基础性能优化配置
|
# 基础性能优化配置
|
||||||
# 启用tcp_nopush以优化数据发送
|
# 启用tcp_nopush以优化数据发送
|
||||||
|
@ -100,7 +100,7 @@ server {
|
||||||
# 仅供内部使用
|
# 仅供内部使用
|
||||||
internal;
|
internal;
|
||||||
# 代理到认证服务
|
# 代理到认证服务
|
||||||
proxy_pass http://192.168.119.194:3000/auth/file;
|
proxy_pass http://192.168.21.194:3000/auth/file;
|
||||||
|
|
||||||
# 请求优化:不传递请求体
|
# 请求优化:不传递请求体
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
|
|
|
@ -11,5 +11,9 @@
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "insiinc",
|
"author": "insiinc",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"echarts-for-react": "^3.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -217,8 +217,6 @@ model Device {
|
||||||
deviceStatus String? @map("device_status")
|
deviceStatus String? @map("device_status")
|
||||||
confidentialLabelId String? @map("confidential_label_id")
|
confidentialLabelId String? @map("confidential_label_id")
|
||||||
confidentialityLevel String? @map("confidentiality_level")
|
confidentialityLevel String? @map("confidentiality_level")
|
||||||
ipAddress String? @map("ip_address")
|
|
||||||
macAddress String? @map("mac_address")
|
|
||||||
diskSerialNumber String? @map("disk_serial_number")
|
diskSerialNumber String? @map("disk_serial_number")
|
||||||
storageLocation String? @map("storage_location")
|
storageLocation String? @map("storage_location")
|
||||||
responsiblePerson String? @map("responsible_person")
|
responsiblePerson String? @map("responsible_person")
|
||||||
|
@ -228,7 +226,7 @@ model Device {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
@@index([deptId])
|
@@index([deptId])
|
||||||
@@index([systemType])
|
@@index([systemType])
|
||||||
@@index([deviceType])
|
@@index([deviceType])
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue