feat: 恭学教育基地管理系统初始提交
- 后端: NestJS 11 + TypeORM + JWT认证 + SQLite/MySQL - 前端: React 19 + Ant Design 6 + Vite 8 + ECharts - 功能模块: 数据面板、学生管理、宿舍管理、入住管理、费用录入、账单管理、教室管理、押金管理、操作日志、账号管理 - 支持Docker一键部署
This commit is contained in:
125
backend/src/classroom-rentals/classroom-rentals.controller.ts
Normal file
125
backend/src/classroom-rentals/classroom-rentals.controller.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('classroom-rentals')
|
||||
export class ClassroomRentalsController {
|
||||
constructor(private service: ClassroomRentalsService, private logService: OperationLogsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('month') month?: string,
|
||||
@Query('includeEnded') includeEnded?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
classroomId: classroomId ? +classroomId : undefined,
|
||||
tenantId: tenantId ? +tenantId : undefined,
|
||||
month,
|
||||
includeEnded: includeEnded === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('schedule')
|
||||
getSchedule(@Query('year') year?: string, @Query('month') month?: string) {
|
||||
const now = new Date();
|
||||
const y = year ? +year : now.getFullYear();
|
||||
const m = month ? +month : now.getMonth() + 1;
|
||||
if (m < 1 || m > 12) throw new BadRequestException('月份必须在 1-12 之间');
|
||||
return this.service.getSchedule(y, m);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateRentalDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental',
|
||||
detail: `教室${dto.classroomId} 租赁方${dto.tenantId} ${dto.startDate}~${dto.endDate}`,
|
||||
ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental',
|
||||
detail: JSON.stringify(dto), ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教室租赁', action: '删除租赁', targetId: +id, targetType: 'classroom-rental',
|
||||
ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 合同上传:multer 限制 10MB + 仅 PDF
|
||||
@Post(':id/contract')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
return cb(new BadRequestException('仅支持 PDF 文件'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}))
|
||||
async uploadContract(@Param('id') id: string, @UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
||||
if (!file) throw new BadRequestException('请上传合同文件');
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.attachContract(+id, file);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental',
|
||||
detail: file.originalname, ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
async downloadContract(@Param('id') id: string, @Res() res: Response) {
|
||||
const { fullPath, originalName } = await this.service.getContractPath(+id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
|
||||
const stream = fs.createReadStream(fullPath);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Delete(':id/contract')
|
||||
async deleteContract(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.removeContract(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id, username: req.user?.username,
|
||||
module: '教室租赁', action: '删除合同', targetId: +id, targetType: 'classroom-rental',
|
||||
ipAddress, userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
16
backend/src/classroom-rentals/classroom-rentals.module.ts
Normal file
16
backend/src/classroom-rentals/classroom-rentals.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRentalsController } from './classroom-rentals.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant]), OperationLogsModule],
|
||||
controllers: [ClassroomRentalsController],
|
||||
providers: [ClassroomRentalsService],
|
||||
exports: [ClassroomRentalsService],
|
||||
})
|
||||
export class ClassroomRentalsModule {}
|
||||
252
backend/src/classroom-rentals/classroom-rentals.service.ts
Normal file
252
backend/src/classroom-rentals/classroom-rentals.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
|
||||
const COLOR_PALETTE = [
|
||||
'#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9',
|
||||
'#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ClassroomRentalsService {
|
||||
constructor(
|
||||
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
|
||||
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
|
||||
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
|
||||
) {}
|
||||
|
||||
get uploadDir(): string {
|
||||
const base = process.env.UPLOAD_DIR || './uploads';
|
||||
return path.resolve(base, 'contracts');
|
||||
}
|
||||
|
||||
ensureUploadDir() {
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(query?: { classroomId?: number; tenantId?: number; month?: string; includeEnded?: boolean }) {
|
||||
const qb = this.repo.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.orderBy('r.startDate', 'DESC');
|
||||
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
|
||||
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
|
||||
if (query?.month) {
|
||||
// month 格式 2026-06,查询当月有重叠的租赁
|
||||
const [y, m] = query.month.split('-').map(Number);
|
||||
const first = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last });
|
||||
}
|
||||
if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' });
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const rental = await this.repo.findOne({ where: { id }, relations: ['classroom', 'tenant'] });
|
||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||
return rental;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找与给定区间冲突的租赁订单
|
||||
* 重叠判定:start1 <= end2 AND start2 <= end1
|
||||
*/
|
||||
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
|
||||
const qb = this.repo.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.where('r.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :end', { end: endDate })
|
||||
.andWhere('r.endDate >= :start', { start: startDate });
|
||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async create(dto: CreateRentalDto, userId?: number) {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
const tenant = await this.tenantRepo.findOne({ where: { id: dto.tenantId } });
|
||||
if (!tenant) throw new NotFoundException('租赁方不存在');
|
||||
|
||||
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
|
||||
if (conflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
message: '该教室在此时间段已有租赁',
|
||||
conflicts: conflicts.map(c => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, tenantName: c.tenant?.name })),
|
||||
});
|
||||
}
|
||||
return this.repo.save(this.repo.create({ ...dto, createdBy: userId, status: 'active' }));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateRentalDto) {
|
||||
const rental = await this.findOne(id);
|
||||
// 若修改了教室/日期,重新冲突检查
|
||||
const newClassroomId = dto.classroomId ?? rental.classroomId;
|
||||
const newStart = dto.startDate ?? rental.startDate;
|
||||
const newEnd = dto.endDate ?? rental.endDate;
|
||||
if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
if (dto.classroomId || dto.startDate || dto.endDate) {
|
||||
const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id);
|
||||
if (conflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
message: '修改后时间段与已有租赁冲突',
|
||||
conflicts: conflicts.map(c => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, tenantName: c.tenant?.name })),
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
// 同时删除合同文件
|
||||
if (rental.contractPath) {
|
||||
const full = path.join(this.uploadDir, rental.contractPath);
|
||||
if (fs.existsSync(full)) {
|
||||
try { fs.unlinkSync(full); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
await this.repo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async attachContract(id: number, file: Express.Multer.File) {
|
||||
const rental = await this.findOne(id);
|
||||
this.ensureUploadDir();
|
||||
// 安全校验:MIME + 扩展名
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
throw new BadRequestException('仅支持 PDF 文件');
|
||||
}
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf');
|
||||
// UUID 文件名
|
||||
const uuid = (globalThis as any).crypto?.randomUUID?.() || require('crypto').randomBytes(16).toString('hex');
|
||||
const filename = `${uuid}.pdf`;
|
||||
const fullPath = path.join(this.uploadDir, filename);
|
||||
// 路径遍历防护
|
||||
if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法');
|
||||
// 删除旧文件
|
||||
if (rental.contractPath) {
|
||||
const oldPath = path.join(this.uploadDir, rental.contractPath);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
try { fs.unlinkSync(oldPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(fullPath, file.buffer);
|
||||
await this.repo.update(id, {
|
||||
contractPath: filename,
|
||||
contractOriginalName: file.originalname,
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async removeContract(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
if (!rental.contractPath) throw new BadRequestException('该租赁未上传合同');
|
||||
const fullPath = path.join(this.uploadDir, rental.contractPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
|
||||
}
|
||||
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
|
||||
return { message: '合同已删除' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同文件的绝对路径(供控制器流式返回),严格校验路径安全
|
||||
*/
|
||||
async getContractPath(id: number): Promise<{ fullPath: string; originalName: string }> {
|
||||
const rental = await this.findOne(id);
|
||||
if (!rental.contractPath) throw new NotFoundException('该租赁未上传合同');
|
||||
const fullPath = path.join(this.uploadDir, rental.contractPath);
|
||||
if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法');
|
||||
if (!fs.existsSync(fullPath)) throw new NotFoundException('合同文件丢失');
|
||||
return { fullPath, originalName: rental.contractOriginalName || 'contract.pdf' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取月度排期矩阵
|
||||
*/
|
||||
async getSchedule(year: number, month: number) {
|
||||
const lastDay = new Date(year, month, 0).getDate();
|
||||
const first = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
const rentals = await this.repo.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
const tenantMap = new Map<number, any>();
|
||||
const matrix: Record<number, Record<number, any>> = {};
|
||||
const summary: Record<number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }> = {};
|
||||
|
||||
for (const cls of classrooms) {
|
||||
matrix[cls.id] = {};
|
||||
summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 };
|
||||
}
|
||||
|
||||
for (const rental of rentals) {
|
||||
const start = new Date(rental.startDate);
|
||||
const end = new Date(rental.endDate);
|
||||
const monthStart = new Date(first);
|
||||
const monthEnd = new Date(last);
|
||||
const effStart = start < monthStart ? monthStart : start;
|
||||
const effEnd = end > monthEnd ? monthEnd : end;
|
||||
if (rental.tenant && !tenantMap.has(rental.tenant.id)) {
|
||||
tenantMap.set(rental.tenant.id, {
|
||||
id: rental.tenant.id,
|
||||
name: rental.tenant.name,
|
||||
color: rental.tenant.color || COLOR_PALETTE[rental.tenant.id % COLOR_PALETTE.length],
|
||||
});
|
||||
}
|
||||
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
|
||||
const day = d.getDate();
|
||||
if (!matrix[rental.classroomId]) continue;
|
||||
matrix[rental.classroomId][day] = {
|
||||
rentalId: rental.id,
|
||||
tenantId: rental.tenantId,
|
||||
tenantName: rental.tenant?.name || '未知',
|
||||
color: rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
|
||||
hasContract: !!rental.contractPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 统计
|
||||
for (const cls of classrooms) {
|
||||
const rented = Object.keys(matrix[cls.id]).length;
|
||||
summary[cls.id].rentedDays = rented;
|
||||
summary[cls.id].idleDays = lastDay - rented;
|
||||
summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
days: lastDay,
|
||||
classrooms: classrooms.map(c => ({ id: c.id, name: c.name, building: c.building, floor: c.floor, roomType: c.roomType, capacity: c.capacity, supervisor: c.supervisor })),
|
||||
tenants: Array.from(tenantMap.values()),
|
||||
matrix,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
}
|
||||
61
backend/src/classroom-rentals/dto/rental.dto.ts
Normal file
61
backend/src/classroom-rentals/dto/rental.dto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateRentalDto {
|
||||
@IsInt()
|
||||
classroomId: number;
|
||||
|
||||
@IsInt()
|
||||
tenantId: number;
|
||||
|
||||
@IsDateString()
|
||||
startDate: string;
|
||||
|
||||
@IsDateString()
|
||||
endDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
dailyRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
totalAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateRentalDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
classroomId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tenantId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
dailyRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
totalAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'ended', 'cancelled'])
|
||||
status?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user