This commit is contained in:
longdayi 2024-09-03 20:19:33 +08:00
parent 835a1ca534
commit 312cfbb658
34 changed files with 1588 additions and 96 deletions

View File

@ -24,12 +24,18 @@
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^4.1.0",
"@nestjs/websockets": "^10.3.10",
"@nicestack/common": "workspace:^",
"@trpc/server": "11.0.0-rc.456",
"axios": "^1.7.3",
"bcrypt": "^5.1.1",
"bullmq": "^5.12.0",
"cron": "^3.1.7",
"dayjs": "^1.11.13",
"exceljs": "^4.4.0",
"ioredis": "^5.4.1",
"mime-types": "^2.1.35",
"reflect-metadata": "^0.2.0",
@ -37,21 +43,20 @@
"socket.io": "^4.7.5",
"superjson-cjs": "^2.2.3",
"tus-js-client": "^4.1.0",
"zod": "^3.23.8",
"dayjs": "^1.11.13",
"exceljs": "^4.4.0"
"zod": "^3.23.8"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/jest": "^29.5.2",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@types/bcrypt": "^5.0.2",
"@types/exceljs": "^1.3.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.2",
"@types/mime-types": "^2.1.4",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",

View File

@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@ -1,6 +1,4 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TrpcModule } from './trpc/trpc.module';
import { RedisService } from './redis/redis.service';
@ -8,11 +6,11 @@ import { RedisModule } from './redis/redis.module';
import { SocketGateway } from './socket/socket.gateway';
import { QueueModule } from './queue/queue.module';
import { TransformModule } from './transform/transform.module';
import { ControllerService } from './controller/controller.service';
import { AuthModule } from './auth/auth.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [TrpcModule, RedisModule, QueueModule, TransformModule],
controllers: [AppController],
providers: [AppService, RedisService, SocketGateway, ControllerService],
imports: [ScheduleModule.forRoot(), TrpcModule, RedisModule, QueueModule, TransformModule, AuthModule],
providers: [RedisService, SocketGateway],
})
export class AppModule { }

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,40 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { env } from '@server/env';
import { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(
token,
{
secret: env.JWT_SECRET
}
);
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtModule } from '@nestjs/jwt';
import { env } from '@server/env';
@Module({
providers: [AuthService],
imports: [JwtModule.register({
global: true,
secret: env.JWT_SECRET,
signOptions: { expiresIn: '60s' },
}),]
})
export class AuthModule { }

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { AuthService } from './auth.service';
import { AuthSchema } from '@nicestack/common';
@Injectable()
export class AuthRouter {
constructor(private readonly trpc: TrpcService, private readonly authService: AuthService) { }
router = this.trpc.router({
login: this.trpc.procedure.input(AuthSchema.signInRequset).mutation(({ input }) => {
return this.authService.signIn(input);
}),
signup: this.trpc.procedure.input(AuthSchema.signUpRequest).mutation(({ input }) => {
return this.authService.signUp(input);
}),
refreshToken: this.trpc.procedure.input(AuthSchema.refreshTokenRequest).mutation(({ input }) => {
return this.authService.refreshToken(input);
}),
logout: this.trpc.protectProcedure.input(AuthSchema.logoutRequest).mutation(({ input }) => {
return this.authService.logout(input);
}),
changePassword: this.trpc.protectProcedure.input(AuthSchema.changePassword).mutation(({ input }) => {
return this.authService.changePassword(input);
}),
});
}

View File

@ -0,0 +1,120 @@
import {
Injectable,
UnauthorizedException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { AuthSchema, db, z } from '@nicestack/common';
import { StaffService } from '@server/models/staff/staff.service';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly staffService: StaffService,
) { }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>) {
const { username, password } = data;
const staff = await db.staff.findUnique({ where: { username } });
if (!staff) {
throw new UnauthorizedException('Invalid username or password');
}
const isPasswordMatch = await bcrypt.compare(password, staff.password);
if (!isPasswordMatch) {
throw new UnauthorizedException('Invalid username or password');
}
const payload = { sub: staff.id, username: staff.username };
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: '1h' });
const refreshToken = await this.generateRefreshToken(staff.id);
// Store the refresh token in the database
await db.refreshToken.create({
data: {
staffId: staff.id,
token: refreshToken,
},
});
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
async generateRefreshToken(userId: string): Promise<string> {
const payload = { sub: userId };
return this.jwtService.signAsync(payload, { expiresIn: '7d' }); // Set an appropriate expiration
}
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
const { refreshToken } = data;
let payload;
try {
payload = this.jwtService.verify(refreshToken);
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
const storedToken = await db.refreshToken.findUnique({ where: { token: refreshToken } });
if (!storedToken) {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await db.staff.findUnique({ where: { id: payload.sub } });
if (!user) {
throw new UnauthorizedException('Invalid refresh token');
}
const newAccessToken = await this.jwtService.signAsync(
{ sub: user.id, username: user.username },
{ expiresIn: '1h' },
);
return {
access_token: newAccessToken,
};
}
async signUp(data: z.infer<typeof AuthSchema.signUpRequest>) {
const { username, password } = data;
const existingUser = await db.staff.findUnique({ where: { username } });
if (existingUser) {
throw new BadRequestException('Username is already taken');
}
const hashedPassword = await bcrypt.hash(password, 10);
const staff = await this.staffService.create({
username,
password: hashedPassword,
});
return staff
}
async logout(data: z.infer<typeof AuthSchema.logoutRequest>) {
const { refreshToken } = data;
await db.refreshToken.deleteMany({ where: { token: refreshToken } });
return { message: 'Logout successful' };
}
async changePassword(data: z.infer<typeof AuthSchema.changePassword>) {
const { oldPassword, newPassword, username } = data;
const user = await db.staff.findUnique({ where: { username } });
if (!user) {
throw new NotFoundException('User not found');
}
const isPasswordMatch = await bcrypt.compare(oldPassword, user.password);
if (!isPasswordMatch) {
throw new UnauthorizedException('Old password is incorrect');
}
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
await this.staffService.update({ id: user.id, password: hashedNewPassword });
return { message: 'Password successfully changed' };
}
}

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

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

View File

@ -1,12 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { HelloService } from './hello.service';
@Injectable()
export class HelloRouter {
constructor(private readonly trpc: TrpcService, private readonly hello: HelloService) { }
router = this.trpc.router({
hello: this.trpc.procedure.query(() => this.hello.helloWorld()),
});
}

View File

@ -1,10 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HelloService {
helloWorld() {
return {
greeting: `Hello world`,
};
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { InitService } from './init.service';
@Module({
providers: [InitService],
exports: [InitService]
})
export class InitModule { }

View File

@ -0,0 +1,90 @@
import { Injectable, Logger } from '@nestjs/common';
import { db, InitRoles, InitTaxonomies, ObjectType } from "@nicestack/common";
import { AuthService } from '@server/auth/auth.service';
@Injectable()
export class InitService {
private readonly logger = new Logger(InitService.name);
constructor(private readonly authService: AuthService) { }
private async createRoles() {
this.logger.log('Checking existing system roles');
for (const role of InitRoles) {
const existingRole = await db.role.findUnique({
where: { name: role.name },
});
if (!existingRole) {
this.logger.log(`Creating role: ${role.name}`);
await db.role.create({
data: { ...role, system: true },
});
} else {
this.logger.log(`Role already exists: ${role.name}`);
}
}
}
private async createTaxonomy() {
this.logger.log('Checking existing taxonomies');
const existingTaxonomies = await db.taxonomy.findMany();
const existingTaxonomyNames = existingTaxonomies.map(taxonomy => taxonomy.name);
for (const [index, taxonomy] of InitTaxonomies.entries()) {
if (!existingTaxonomyNames.includes(taxonomy.name)) {
this.logger.log(`Creating taxonomy: ${taxonomy.name}`);
await db.taxonomy.create({
data: {
...taxonomy,
order: index,
},
});
} else {
this.logger.log(`Taxonomy already exists: ${taxonomy.name}`);
}
}
}
private async createRoot() {
this.logger.log('Checking for root account');
const rootAccountExists = await db.staff.findUnique({
where: { phoneNumber: process.env.ADMIN_PHONE_NUMBER || '000000' },
});
if (!rootAccountExists) {
this.logger.log('Creating root account');
const rootStaff =await this.authService.signUp({
username: 'root',
password: 'admin'
})
const rootRole = await db.role.findUnique({
where: { name: '根管理员' },
});
if (rootRole) {
this.logger.log('Assigning root role to root account');
await db.roleMap.create({
data: {
objectType: ObjectType.STAFF,
objectId: rootStaff.id,
roleId: rootRole.id,
},
});
} else {
this.logger.error('Root role does not exist');
}
} else {
this.logger.log('Root account already exists');
}
}
async init() {
this.logger.log('Initializing system roles');
await this.createRoles();
this.logger.log('Initializing root account');
await this.createRoot();
this.logger.log('Initializing taxonomies');
await this.createTaxonomy();
}
}

View File

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

View File

@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentService } from './department.service'; // assuming it's in the same directory
import { DepartmentSchema, z } from '@nicestack/common';
@Injectable()
export class DepartmentRouter {
constructor(
private readonly trpc: TrpcService,
private readonly departmentService: DepartmentService, // inject DepartmentService
) {}
router = this.trpc.router({
create: this.trpc.protectProcedure
.input(DepartmentSchema.create) // expect input according to the schema
.mutation(async ({ input }) => {
return this.departmentService.create(input);
}),
update: this.trpc.protectProcedure
.input(DepartmentSchema.update) // expect input according to the schema
.mutation(async ({ input }) => {
return this.departmentService.update(input);
}),
delete: this.trpc.protectProcedure
.input(DepartmentSchema.delete) // expect input according to the schema
.mutation(async ({ input }) => {
return this.departmentService.delete(input);
}),
getDepartmentDetails: this.trpc.procedure
.input(z.object({ deptId: z.string() })) // expect an object with deptId
.query(async ({ input }) => {
return this.departmentService.getDepartmentDetails(input.deptId);
}),
getAllChildDeptIds: this.trpc.procedure
.input(z.object({ deptId: z.string() })) // expect an object with deptId
.query(async ({ input }) => {
return this.departmentService.getAllChildDeptIds(input.deptId);
}),
getAllParentDeptIds: this.trpc.procedure
.input(z.object({ deptId: z.string() })) // expect an object with deptId
.query(async ({ input }) => {
return this.departmentService.getAllParentDeptIds(input.deptId);
}),
getChildren: this.trpc.procedure
.input(z.object({ parentId: z.string().nullish() }))
.query(async ({ input }) => {
return this.departmentService.getChildren(input.parentId);
}),
getDomainDepartments: this.trpc.procedure
.input(z.object({ query: z.string().nullish() }))
.query(async ({ input }) => {
const { query } = input;
return this.departmentService.getDomainDepartments(query);
}),
findMany: this.trpc.procedure
.input(DepartmentSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.departmentService.findMany(input);
}),
paginate: this.trpc.procedure
.input(DepartmentSchema.paginate) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.departmentService.paginate(input);
}),
});
}

View File

@ -0,0 +1,365 @@
import { Injectable } from '@nestjs/common';
import { db, z, DepartmentSchema } from '@nicestack/common';
@Injectable()
export class DepartmentService {
/**
*
* @param deptId -
* @returns deptId则返回undefined
*/
async getFlatChildDepts(deptId: string) {
if (!deptId) return undefined;
return await db.deptAncestry.findMany({
where: { ancestorId: deptId },
});
}
/**
* DOM下的对应name的单位
* @param domId
* @param name
* @returns
*
*
*/
async findByNameInDom(domId: string, name: string) {
const subDepts = await db.deptAncestry.findMany({
where: {
ancestorId: domId,
},
include: {
descendant: true,
},
});
const dept = subDepts.find((item) => item.descendant.name === name);
return dept?.descendant;
}
/**
*
* @param deptId -
* @returns deptId则返回undefined
*/
async getFlatParentDepts(deptId: string) {
if (!deptId) return undefined;
return await db.deptAncestry.findMany({
where: { descendantId: deptId },
});
}
/**
* ID
* @param deptId -
* @returns ID的数组
*/
async getAllChildDeptIds(deptId: string) {
const res = await this.getFlatChildDepts(deptId);
return res?.map((dept) => dept.descendantId) || [];
}
/**
* ID
* @param deptId -
* @returns ID的数组
*/
async getAllParentDeptIds(deptId: string) {
const res = await this.getFlatParentDepts(deptId);
return res?.map((dept) => dept.ancestorId) || [];
}
/**
*
* @param deptId -
* @returns
*/
async getDepartmentDetails(deptId: string) {
const department = await db.department.findUnique({
where: { id: deptId },
include: { children: true, deptStaffs: true },
});
const childrenData = await db.deptAncestry.findMany({
where: { ancestorId: deptId, relDepth: 1 },
include: { descendant: { include: { children: true } } },
});
const children = childrenData.map(({ descendant }) => ({
id: descendant.id,
name: descendant.name,
order: descendant.order,
parentId: descendant.parentId,
hasChildren: Boolean(descendant.children?.length),
childrenCount: descendant.children?.length || 0,
}));
return {
id: department?.id,
name: department?.name,
order: department?.order,
parentId: department?.parentId,
children,
staffs: department?.deptStaffs,
hasChildren: !!children.length,
};
}
/**
*
* @param parentId -
* @returns
*/
async getChildren(parentId?: string) {
const departments = await db.department.findMany({
where: { parentId: parentId ?? null },
include: { children: true, deptStaffs: true },
});
return departments.map((dept) => ({
...dept,
hasChildren: dept.children.length > 0,
staffs: dept.deptStaffs,
}));
}
async paginate(data: z.infer<typeof DepartmentSchema.paginate>) {
const { page, pageSize, ids } = data;
const [items, totalCount] = await Promise.all([
db.department.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
where: {
deletedAt: null,
OR: [{ id: { in: ids } }],
},
include: { deptStaffs: true, parent: true },
orderBy: { order: 'asc' },
}),
db.department.count({
where: {
deletedAt: null,
OR: [{ id: { in: ids } }],
},
}),
]);
return { items, totalCount };
}
async findMany(data: z.infer<typeof DepartmentSchema.findMany>) {
const { keyword = '', ids } = data;
const departments = await db.department.findMany({
where: {
deletedAt: null,
OR: [{ name: { contains: keyword! } }, ids ? { id: { in: ids } } : {}],
},
include: { deptStaffs: true },
orderBy: { order: 'asc' },
take: 20,
});
return departments.map((dept) => ({
...dept,
staffs: dept.deptStaffs,
}));
}
/**
*
* @param query -
* @returns
*/
async getDomainDepartments(query?: string) {
return await db.department.findMany({
where: { isDomain: true, name: { contains: query } },
take: 10,
});
}
async getDeptIdsByStaffIds(ids: string[]) {
const staffs = await db.staff.findMany({
where: { id: { in: ids } },
});
return staffs.map((staff) => staff.deptId);
}
/**
* DeptAncestry关系
* @param data -
* @returns
*/
async create(data: z.infer<typeof DepartmentSchema.create>) {
let newOrder = 0;
// 确定新单位的顺序
const siblingDepartments = await db.department.findMany({
where: { parentId: data.parentId ?? null },
orderBy: { order: 'desc' },
take: 1,
});
if (siblingDepartments.length > 0) {
newOrder = siblingDepartments[0].order + 1;
}
// 根据计算的顺序创建新单位
const newDepartment = await db.department.create({
data: { ...data, order: newOrder },
});
// 如果存在parentId则更新DeptAncestry关系
if (data.parentId) {
const parentAncestries = await db.deptAncestry.findMany({
where: { descendantId: data.parentId },
orderBy: { relDepth: 'asc' },
});
// 为新单位创建新的祖先记录
const newAncestries = parentAncestries.map((ancestry) => ({
ancestorId: ancestry.ancestorId,
descendantId: newDepartment.id,
relDepth: ancestry.relDepth + 1,
}));
newAncestries.push({
ancestorId: data.parentId,
descendantId: newDepartment.id,
relDepth: 1,
});
await db.deptAncestry.createMany({ data: newAncestries });
}
return newDepartment;
}
/**
* parentId更改时管理DeptAncestry关系
* @param data -
* @returns
*/
async update(data: z.infer<typeof DepartmentSchema.update>) {
return await db.$transaction(async (transaction) => {
const currentDepartment = await transaction.department.findUnique({
where: { id: data.id },
});
if (!currentDepartment) throw new Error('Department not found');
const updatedDepartment = await transaction.department.update({
where: { id: data.id },
data: data,
});
if (data.parentId !== currentDepartment.parentId) {
await transaction.deptAncestry.deleteMany({
where: { descendantId: data.id },
});
if (data.parentId) {
const parentAncestries = await transaction.deptAncestry.findMany({
where: { descendantId: data.parentId },
});
const newAncestries = parentAncestries.map((ancestry) => ({
ancestorId: ancestry.ancestorId,
descendantId: data.id,
relDepth: ancestry.relDepth + 1,
}));
newAncestries.push({
ancestorId: data.parentId,
descendantId: data.id,
relDepth: 1,
});
await transaction.deptAncestry.createMany({ data: newAncestries });
}
}
return updatedDepartment;
});
}
/**
* DeptAncestry关系
* @param data -
* @returns
*/
async delete(data: z.infer<typeof DepartmentSchema.delete>) {
const deletedDepartment = await db.department.update({
where: { id: data.id },
data: { deletedAt: new Date() },
});
await db.deptAncestry.deleteMany({
where: { OR: [{ ancestorId: data.id }, { descendantId: data.id }] },
});
return deletedDepartment;
}
async getStaffsByDeptIds(ids: string[]) {
const depts = await db.department.findMany({
where: { id: { in: ids } },
include: { deptStaffs: true },
});
return depts.flatMap((dept) => dept.deptStaffs);
}
/**
*
* @param deptIds - ID的部门ID数组
* @returns ID的数组
*/
async getAllStaffsByDepts(deptIds: string[]) {
const allDeptIds = new Set(deptIds);
for (const deptId of deptIds) {
const childDeptIds = await this.getAllChildDeptIds(deptId);
childDeptIds.forEach((id) => allDeptIds.add(id));
}
return await this.getStaffsByDeptIds(Array.from(allDeptIds));
}
/**
* ID获取部门ID
*
* @param {string} name -
* @param {string} domainId -
* @returns {Promise<string | null>} - IDnull
*/
async getDeptIdByName(name: string, domainId: string): Promise<string | null> {
const dept = await db.department.findFirst({
where: {
name,
ancestors: {
some: {
ancestorId: domainId
}
}
}
});
return dept ? dept.id : null;
}
/**
* ID获取多个部门的ID
*
* @param {string[]} names -
* @param {string} domainId -
* @returns {Promise<Record<string, string | null>>} - ID或null的记录
*/
async getDeptIdsByNames(names: string[], domainId: string): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {};
// 遍历每个部门名称并获取对应的部门ID
for (const name of names) {
// 使用之前定义的函数根据名称获取部门ID
const deptId = await this.getDeptIdByName(name, domainId);
result[name] = deptId;
}
return result;
}
}

View File

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

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { StaffService } from './staff.service'; // Adjust the import path as necessary
import { z, StaffSchema } from '@nicestack/common';
@Injectable()
export class StaffRouter {
constructor(
private readonly trpc: TrpcService,
private readonly staffService: StaffService,
) {}
router = this.trpc.router({
create: this.trpc.procedure
.input(StaffSchema.create) // Assuming StaffSchema.create is the Zod schema for creating staff
.mutation(async ({ input }) => {
return await this.staffService.create(input);
}),
update: this.trpc.procedure
.input(StaffSchema.update) // Assuming StaffSchema.update is the Zod schema for updating staff
.mutation(async ({ input }) => {
return await this.staffService.update(input);
}),
batchDelete: this.trpc.procedure
.input(StaffSchema.batchDelete) // Assuming StaffSchema.batchDelete is the Zod schema for batch deleting staff
.mutation(async ({ input }) => {
return await this.staffService.batchDelete(input);
}),
paginate: this.trpc.procedure
.input(StaffSchema.paginate) // Define the input schema for pagination
.query(async ({ input }) => {
return await this.staffService.paginate(input);
}),
findByDept: this.trpc.procedure
.input(StaffSchema.findByDept)
.query(async ({ input }) => {
return await this.staffService.findByDept(input);
}),
findMany: this.trpc.procedure
.input(StaffSchema.findMany) // Assuming StaffSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => {
return await this.staffService.findMany(input);
}),
findUnique: this.trpc.procedure
.input(StaffSchema.findUnique)
.query(async ({ input }) => {
return await this.staffService.findUnique(input);
}),
});
}

View File

@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HelloService } from './hello.service';
import { StaffService } from './staff.service';
describe('HelloService', () => {
let service: HelloService;
describe('StaffService', () => {
let service: StaffService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HelloService],
providers: [StaffService],
}).compile();
service = module.get<HelloService>(HelloService);
service = module.get<StaffService>(StaffService);
});
it('should be defined', () => {

View File

@ -0,0 +1,178 @@
import { Injectable } from '@nestjs/common';
import { db, ObjectType, Staff, StaffSchema, z } from '@nicestack/common';
import { TRPCError } from '@trpc/server';
import { DepartmentService } from '../department/department.service';
@Injectable()
export class StaffService {
constructor(private readonly departmentService: DepartmentService) { }
/**
* staff的记录
* @param deptId id
* @returns staff记录
*/
async findByDept(data: z.infer<typeof StaffSchema.findByDept>) {
const { deptId, domainId } = data;
const childDepts = await this.departmentService.getAllChildDeptIds(deptId);
const result = await db.staff.findMany({
where: {
deptId: { in: [...childDepts, deptId] },
domainId,
},
});
return result;
}
/**
*
* @param data
* @returns
*/
async create(data: z.infer<typeof StaffSchema.create>) {
const { ...others } = data;
try {
return await db.$transaction(async (transaction) => {
// 获取当前最大order值
const maxOrder = await transaction.staff.aggregate({
_max: { order: true },
});
// 新员工的order值比现有最大order值大1
const newOrder = (maxOrder._max.order ?? -1) + 1;
// 创建新员工记录
const newStaff = await transaction.staff.create({
data: { ...others, order: newOrder },
include: { domain: true, department: true },
});
return newStaff;
});
} catch (error) {
console.error('Failed to create staff:', error);
throw error;
}
}
/**
*
* @param data id和其他更新字段的对象
* @returns
*/
async update(data: z.infer<typeof StaffSchema.update>) {
const { id, ...others } = data;
try {
return await db.$transaction(async (transaction) => {
// 更新员工记录
const updatedStaff = await transaction.staff.update({
where: { id },
data: others,
include: { domain: true, department: true },
});
return updatedStaff;
});
} catch (error) {
console.error('Failed to update staff:', error);
throw error;
}
}
/**
*
* @param data ID数组的对象
* @returns
*/
async batchDelete(data: z.infer<typeof StaffSchema.batchDelete>) {
const { ids } = data;
if (!ids || ids.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No IDs provided for deletion.',
});
}
const deletedStaffs = await db.staff.updateMany({
where: { id: { in: ids } },
data: { deletedAt: new Date() },
});
if (!deletedStaffs.count) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No taxonomies were found with the provided IDs.',
});
}
return { success: true, count: deletedStaffs.count };
}
/**
*
* @param data ID和部门ID的对象
* @returns
*/
async paginate(data: z.infer<typeof StaffSchema.paginate>) {
const { page, pageSize, domainId, deptId, ids } = data;
const childDepts = await this.departmentService.getAllChildDeptIds(deptId);
const [items, totalCount] = await Promise.all([
db.staff.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { order: 'asc' },
where: {
id: ids ? { in: ids } : undefined,
deletedAt: null,
domainId,
deptId: deptId ? { in: [...childDepts, deptId] } : undefined,
},
include: { domain: true, department: true },
}),
db.staff.count({
where: {
deletedAt: null,
domainId,
deptId: deptId ? { in: [...childDepts, deptId] } : undefined,
},
}),
]);
const processedItems = await Promise.all(
items.map((item) => this.genStaffDto(item)),
);
return { items: processedItems, totalCount };
}
/**
* ID集合查找员工
* @param data ID和ID集合的对象
* @returns
*/
async findMany(data: z.infer<typeof StaffSchema.findMany>) {
const { keyword, domainId, ids } = data;
return await db.staff.findMany({
where: {
deletedAt: null,
domainId,
OR: [
{ username: { contains: keyword } },
{
id: { in: ids },
},
],
},
orderBy: { order: 'asc' },
take: 10,
});
}
/**
* DTO
* @param staff
* @returns ID列表的员工DTO
*/
private async genStaffDto(staff: Staff) {
const roleMaps = await db.roleMap.findMany({
where: {
domainId: staff.domainId,
objectId: staff.id,
objectType: ObjectType.STAFF,
},
include: { role: true },
});
const roleIds = roleMaps.map((roleMap) => roleMap.role.id);
return { ...staff, roleIds };
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { InitModule } from '@server/init/init.module';
@Module({
imports: [InitModule],
providers: [TasksService]
})
export class TasksModule { }

View File

@ -0,0 +1,27 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { InitService } from '@server/init/init.service';
import { CronJob } from 'cron';
@Injectable()
export class TasksService implements OnModuleInit {
private readonly logger = new Logger(TasksService.name);
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly initService: InitService,
) { }
async onModuleInit() {
this.logger.log('Main node launch');
await this.initService.init();
const handleCronJob = new CronJob('0 * * * *', () => {
this.logger.log('cron job test');
});
this.schedulerRegistry.addCronJob('cronJob', handleCronJob);
this.logger.log('Start cron job');
handleCronJob.start();
}
}

View File

@ -1,21 +1,19 @@
import { INestApplication, Injectable } from '@nestjs/common';
import { AuthRouter } from '@server/auth/auth.router';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';
import { HelloRouter } from '@server/hello/hello.router';
@Injectable()
export class TrpcRouter {
constructor(private readonly trpc: TrpcService, private readonly hello: HelloRouter) { }
constructor(private readonly trpc: TrpcService, private readonly auth: AuthRouter) { }
appRouter = this.trpc.router({
hello: this.hello.router
auth: this.auth.router
});
async applyMiddleware(app: INestApplication) {
app.use(
`/trpc`,
trpcExpress.createExpressMiddleware({
router: this.appRouter,
createContext: this.trpc.createContext
}),
);
}

View File

@ -1,13 +1,59 @@
import { Injectable } from '@nestjs/common';
import { initTRPC } from '@trpc/server';
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson-cjs';
import * as trpcExpress from '@trpc/server/adapters/express';
import { env } from '@server/env';
import { db, Staff, TokenPayload } from "@nicestack/common"
import { JwtService } from '@nestjs/jwt';
type Context = Awaited<ReturnType<TrpcService['createContext']>>;
@Injectable()
export class TrpcService {
trpc = initTRPC.create({
transformer: superjson
constructor(private readonly jwtService: JwtService) { }
async createContext({
req,
res,
}: trpcExpress.CreateExpressContextOptions) {
const token = req.headers.authorization?.split(' ')[1];
let tokenData: TokenPayload | undefined = undefined;
let staff: Staff | undefined = undefined;
if (token) {
try {
// Verify JWT token and extract tokenData
tokenData = await this.jwtService.verifyAsync(token, { secret: env.JWT_SECRET }) as TokenPayload;
if (tokenData) {
// Fetch staff details from the database using tokenData.id
staff = await db.staff.findUnique({ where: { id: tokenData.id } });
if (!staff) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: "User not found" });
}
}
} catch (error) {
// Enhanced error handling for invalid session data or token verification failure
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "Invalid session data or token" });
}
}
return {
staff,
};
};
trpc = initTRPC.context<Context>().create({
transformer: superjson,
});
procedure = this.trpc.procedure;
router = this.trpc.router;
mergeRouters = this.trpc.mergeRouters;
// Define a protected procedure that ensures the user is authenticated
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
if (!ctx.staff) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: "Unauthorized request" });
}
return next({
ctx: {
// User value is confirmed to be non-null at this point
staff: ctx.staff,
},
});
});
}

View File

@ -10,6 +10,7 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"strictNullChecks": false,
// "baseUrl": "./",
// "incremental": true,
// "skipLibCheck": true,

View File

@ -20,7 +20,7 @@ services:
- ./volumes/minio:/minio_data
environment:
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_JWT_SECRET=minioadmin
command: minio server /minio_data --console-address ":9001" -address ":9000"
healthcheck:
test:

View File

@ -0,0 +1,162 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Taxonomy {
id String @id @default(uuid())
name String @unique
deletedAt DateTime?
terms Term[]
order Int
@@index([order, deletedAt])
}
model Relation {
id String @id @default(uuid())
aId String
bId String
aType String
bType String
relationType String
createdAt DateTime? @default(now())
@@unique([aId, bId, aType, bType, relationType])
@@map("relations")
}
model Term {
id String @id @default(uuid())
name String
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
taxonomyId String?
order Int
description String?
parentId String?
parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade)
children Term[] @relation("ChildParent")
ancestors TermAncestry[] @relation("DescendantToAncestor")
descendants TermAncestry[] @relation("AncestorToDescendant")
domainId String?
domain Department? @relation("TermDom", fields: [domainId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
createdBy String
createdStaff Staff? @relation(fields: [staffId], references: [id])
staffId String?
@@index([name]) // 对name字段建立索引以加快基于name的查找速度
@@index([parentId]) // 对parentId字段建立索引以加快基于parentId的查找速度
@@map("terms")
}
model TermAncestry {
id String @id @default(uuid())
ancestorId String
descendantId String
relDepth Int
ancestor Term @relation("AncestorToDescendant", fields: [ancestorId], references: [id], onDelete: Cascade)
descendant Term @relation("DescendantToAncestor", fields: [descendantId], references: [id], onDelete: Cascade)
createdAt DateTime? @default(now())
}
model Comment {
id String @id @default(uuid())
style String
link String?
title String?
content String
attachments String[] @default([])
createdAt DateTime? @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
createdBy String?
createdStaff Staff? @relation(fields: [createdBy], references: [id])
@@map("comments")
}
model Staff {
id String @id @default(uuid())
username String @unique
password String
phoneNumber String? @unique
domainId String?
deptId String?
domain Department? @relation("DomainStaff", fields: [domainId], references: [id])
department Department? @relation("DeptStaff", fields: [deptId], references: [id])
registerToken String?
order Int
deletedAt DateTime?
system Boolean? @default(false)
comments Comment[]
terms Term[]
refreshTokens RefreshToken[]
}
model RefreshToken {
id String @id @default(uuid())
token String @unique
staffId String
staff Staff @relation(fields: [staffId], references: [id])
createdAt DateTime @default(now())
@@map("refreshTokens")
}
model Department {
id String @id @default(uuid())
name String
order Int
ancestors DeptAncestry[] @relation("DescendantToAncestor")
descendants DeptAncestry[] @relation("AncestorToDescendant")
parentId String? @map("parentId")
parent Department? @relation("ChildParent", fields: [parentId], references: [id])
children Department[] @relation("ChildParent")
domainTerms Term[] @relation("TermDom")
deletedAt DateTime?
isDomain Boolean? @default(false)
domainStaffs Staff[] @relation("DomainStaff")
deptStaffs Staff[] @relation("DeptStaff")
}
model DeptAncestry {
ancestorId String
descendantId String
relDepth Int
ancestor Department @relation("AncestorToDescendant", fields: [ancestorId], references: [id])
descendant Department @relation("DescendantToAncestor", fields: [descendantId], references: [id])
@@id([descendantId, ancestorId])
@@index([ancestorId]) // 对ancestorId字段建立索引以加快基于ancestorId的查找速度
@@index([descendantId]) // 对descendantId字段建立索引以加快基于descendantId的查找速度
}
model RoleMap {
id String @id @default(uuid())
objectId String
roleId String
domainId String?
objectType String
role Role @relation(fields: [roleId], references: [id])
}
model Role {
id String @id @default(uuid())
name String @unique
permissions String[] @default([])
roleMaps RoleMap[]
deletedAt DateTime?
system Boolean? @default(false)
}

View File

@ -0,0 +1,55 @@
import { RolePerms } from "./enum";
export const InitRoles: { name: string, permissions: string[], system?: boolean }[] = [
{
name: "基层",
permissions: [
RolePerms.CREATE_TROUBLE,
RolePerms.CREATE_WORKPROGRESS,
]
},
{
name: "机关",
permissions: [
RolePerms.CREATE_TROUBLE,
RolePerms.CREATE_WORKPROGRESS,
],
},
{
name: "领导",
permissions: [
RolePerms.READ_DOM_TROUBLE,
RolePerms.CREATE_INSTRUCTION,
],
},
{
name: "域管理员",
permissions: Object.keys(RolePerms).filter(
(perm) =>
![
RolePerms.READ_ANY_CHART,
RolePerms.READ_ANY_TROUBLE,
RolePerms.READ_ANY_TERM,
RolePerms.PROCESS_ANY_ASSESSMENT,
RolePerms.PROCESS_ANY_TROUBLE,
RolePerms.EDIT_ROOT_OPTION,
RolePerms.EDIT_ANY_TERM,
RolePerms.EDIT_ANY_TROUBLE,
RolePerms.EDIT_ANY_ASSESSMENT,
RolePerms.DELETE_ANY_TROUBLE,
RolePerms.DELETE_ANY_TERM,
RolePerms.DELETE_ANY_ASSESSMENT,
].includes(perm as any)
) as RolePerms[],
},
{
name: "根管理员",
permissions: Object.keys(RolePerms) as RolePerms[],
},
];
export const InitTaxonomies: { name: string }[] = [{
name: '分类'
},
{
name: '研判单元'
}]

View File

@ -0,0 +1,57 @@
export enum ObjectType {
DEPARTMENT = "DEPARTMENT",
STAFF = "STAFF",
COMMENT = "COMMENT",
TERM = "TERM",
}
export enum RelationType {
WATCH = "WATCH",
READED = "READED",
MESSAGE = "MESSAGE",
}
export enum RolePerms {
// Create Permissions 创建权限
CREATE_ALERT = "CREATE_ALERT", // 创建警报
CREATE_INSTRUCTION = "CREATE_INSTRUCTION", // 创建指令
CREATE_TROUBLE = "CREATE_TROUBLE", // 创建问题
CREATE_WORKPROGRESS = "CREATE_WORKPROGRESS", // 创建工作进度
CREATE_ASSESSMENT = "CREATE_ASSESSMENT", // 创建评估
CREATE_TERM = "CREATE_TERM", // 创建术语
// Read Permissions 读取权限
READ_ANY_TROUBLE = "READ_ANY_TROUBLE", // 读取任何问题
READ_DOM_TROUBLE = "READ_DOM_TROUBLE", // 读取领域问题
READ_ANY_CHART = "READ_ANY_CHART", // 读取任何图表
READ_DOM_CHART = "READ_DOM_CHART", // 读取领域图表
READ_ANY_ASSESSMENT = "READ_ANY_ASSESSMENT", // 读取任何评估
READ_DOM_ASSESSMENT = "READ_DOM_ASSESSMENT", // 读取领域评估
READ_ANY_TERM = "READ_ANY_TERM", // 读取任何术语
READ_DOM_TERM = "READ_DOM_TERM", // 读取领域术语
// Edit Permissions 编辑权限
EDIT_DOM_TROUBLE = "EDIT_DOM_TROUBLE", // 编辑领域问题
EDIT_ANY_TROUBLE = "EDIT_ANY_TROUBLE", // 编辑任何问题
EDIT_DOM_ROLE = "EDIT_DOM_ROLE", // 编辑领域角色
EDIT_ROOT_OPTION = "EDIT_ROOT_OPTION", // 编辑根选项
EDIT_DOM_ASSESSMENT = "EDIT_DOM_ASSESSMENT", // 编辑领域评估
EDIT_ANY_ASSESSMENT = "EDIT_ANY_ASSESSMENT", // 编辑任何评估
EDIT_DOM_TERM = "EDIT_DOM_TERM", // 编辑领域术语
EDIT_ANY_TERM = "EDIT_ANY_TERM", // 编辑任何术语
// Delete Permissions 删除权限
DELETE_DOM_TROUBLE = "DELETE_DOM_TROUBLE", // 删除领域问题
DELETE_ANY_TROUBLE = "DELETE_ANY_TROUBLE", // 删除任何问题
DELETE_DOM_ASSESSMENT = "DELETE_DOM_ASSESSMENT", // 删除领域评估
DELETE_ANY_ASSESSMENT = "DELETE_ANY_ASSESSMENT", // 删除任何评估
DELETE_DOM_TERM = "DELETE_DOM_TERM", // 删除领域术语
DELETE_ANY_TERM = "DELETE_ANY_TERM", // 删除任何术语
// Process Permissions 处理权限
PROCESS_DOM_TROUBLE = "PROCESS_DOM_TROUBLE", // 处理领域问题
PROCESS_ANY_TROUBLE = "PROCESS_ANY_TROUBLE", // 处理任何问题
PROCESS_DOM_ASSESSMENT = "PROCESS_DOM_ASSESSMENT", // 处理领域评估
PROCESS_ANY_ASSESSMENT = "PROCESS_ANY_ASSESSMENT", // 处理任何评估
// Audit Permissions 审核权限
AUDIT_TROUBLE = "AUDIT_TROUBLE", // 审核问题
}

View File

@ -1,4 +1,8 @@
export * from "@prisma/client"
export * from "zod"
export * from "./db"
export * from '@prisma/client';
export * from "./schema"
export * from "./schema"
export * from "./enum"
export * from "./type"
export * from "./utils"
export * from "./constants"

View File

@ -1 +1,92 @@
export { }
import { z } from "zod"
export const AuthSchema = {
signInRequset: z.object({
username: z.string(),
password: z.string(),
}),
signUpRequest: z.object({
username: z.string(),
password: z.string(),
}),
changePassword: z.object({
username: z.string(),
oldPassword: z.string(),
newPassword: z.string(),
}),
refreshTokenRequest: z.object({
refreshToken: z.string(),
}),
logoutRequest: z.object({
refreshToken: z.string(),
}),
};
export const StaffSchema = {
create: z.object({
username: z.string(),
password: z.string(),
domainId: z.string().nullish(),
}),
update: z.object({
id: z.string(),
name: z.string().nullish(),
password: z.string().nullish(),
domainId: z.string().nullish(),
deptId: z.string().nullish(),
phoneNumber: z.string().nullish(),
order: z.number().nullish(),
registerToken: z.string().nullish(),
}),
delete: z.object({
id: z.string(),
}),
batchDelete: z.object({
ids: z.array(z.string()),
}),
findByDept: z.object({
deptId: z.string(),
domainId: z.string().nullish(),
}),
findMany: z.object({
keyword: z.string().nullish(),
domainId: z.string().nullish(),
ids: z.array(z.string()).nullish(),
}),
findUnique: z.object({
id: z.string().nullish(),
}),
paginate: z.object({
page: z.number(),
pageSize: z.number(),
domainId: z.string().nullish(),
deptId: z.string().nullish(),
ids: z.array(z.string()).nullish(),
}),
};
export const DepartmentSchema = {
create: z.object({
name: z.string(),
parentId: z.string().nullish(),
isDomain: z.boolean().nullish(),
}),
update: z.object({
id: z.string(),
name: z.string().nullish(),
parentId: z.string().nullish(),
deletedAt: z.date().nullish(),
order: z.number().nullish(),
isDomain: z.boolean().nullish(),
}),
delete: z.object({
id: z.string(),
}),
findMany: z.object({
keyword: z.string().nullish(),
ids: z.array(z.string()).nullish(),
}),
paginate: z.object({
page: z.number(),
pageSize: z.number(),
ids: z.array(z.string()).nullish(),
}),
};

View File

@ -0,0 +1,19 @@
import { Department, Staff } from "@prisma/client";
export interface DataNode {
title: any;
key: string;
hasChildren?: boolean;
children?: DataNode[];
value: string;
data?: any;
isLeaf?: boolean;
}
export type StaffDto = Staff & {
domain?: Department;
department?: Department;
};
export interface TokenPayload {
id: string;
username: string;
}

View File

@ -0,0 +1,106 @@
import { Staff } from "@prisma/client";
import { DataNode } from "./type";
export function findNodeByKey(
nodes: DataNode[],
targetKey: string
): DataNode | null {
let result: DataNode | null = null;
for (const node of nodes) {
if (node.key === targetKey) {
return node;
}
if (node.children && node.children.length > 0) {
result = findNodeByKey(node.children, targetKey);
if (result) {
return result;
}
}
}
return result;
}
export function findStaffById(
nodes: DataNode[],
staffId: string
): Staff | null {
for (const node of nodes) {
// 在当前节点的staffs数组中查找
const foundStaff = node?.data?.staffs.find(
(staff: Staff) => staff.id === staffId
);
if (foundStaff) {
return foundStaff;
}
// 如果当前节点的staffs数组中没有找到则递归在子节点中查找
if (node.children) {
const foundInChildren = findStaffById(node.children, staffId);
if (foundInChildren) {
return foundInChildren;
}
}
}
// 如果在所有节点及其子节点中都没有找到返回null
return null;
}
interface MappingConfig {
titleField?: string;
keyField?: string;
valueField?: string;
hasChildrenField?: string; // Optional, in case the structure has nested items
childrenField?: string;
}
export function mapToDataNodes(
inputArray: any[],
config: MappingConfig = {}
): DataNode[] {
const {
titleField = "title",
keyField = "key",
valueField = "value",
hasChildrenField = "hasChildren",
childrenField = "children"
} = config;
return inputArray.map((item) => {
const hasChildren = item[hasChildrenField] || false;
const children = item[childrenField]
return {
title: item[titleField] || "",
key: item[keyField] || "",
value: item[valueField] || null,
data: item,
children: children
? mapToDataNodes(children, { titleField, keyField, valueField, hasChildrenField, childrenField })
: undefined,
hasChildren
};
});
}
/**
*
*
*
*
*
* @template T -
* @param {T[]} array1 -
* @param {T[]} array2 -
* @returns {T[]}
*
* @example
* const array1 = [1, 2, 3, 4];
* const array2 = [3, 4, 5, 6];
* const result = mergeAndDeduplicate(array1, array2);
* console.log(result); // 输出: [1, 2, 3, 4, 5, 6]
*/
export function mergeAndDeduplicate<T = string>(array1: T[], array2: T[]): T[] {
const set = new Set([...array1, ...array2]);
return Array.from(set);
}