feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -25,7 +25,7 @@ import {
} from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@@ -78,31 +78,11 @@ export class DepositsController {
@Post()
@RequirePermission('deposit:create')
async create(@Body() dto: CreateDepositDto, @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: 'deposit',
detail: `学生${dto.studentId} ¥${dto.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
});
// Send deposit_due notification
try {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_due',
title: '押金待缴',
content: `您有一笔押金待缴纳,金额: ¥${dto.amount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);
return result;
}
@@ -110,17 +90,9 @@ export class DepositsController {
@Post('batch')
@RequirePermission('deposit:create')
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreate(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '批量收取押金',
targetType: 'deposit',
detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
});
return result;
}
@@ -132,18 +104,9 @@ export class DepositsController {
@Body() body: CreateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.addInstallment(id, body.amount, body.dueDate);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '新增分期',
targetId: result.id,
targetType: 'deposit-installment',
detail: `押金${id} 新增分期 ¥${result.amount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '新增分期', targetId: result.id, targetType: 'deposit-installment', detail: `押金${id} 新增分期 ¥${result.amount}`,
});
return result;
}
@@ -155,18 +118,9 @@ export class DepositsController {
@Body() body: UpdateDepositInstallmentDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateInstallment(installmentId, body);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '更新分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '更新分期', targetId: installmentId, targetType: 'deposit-installment', detail: `更新分期${installmentId}, 状态:${result.status ?? '-'}, 实付日:${result.paidDate ?? '-'}`,
});
return result;
}
@@ -177,18 +131,9 @@ export class DepositsController {
@Param('installmentId', ParseIntPipe) installmentId: number,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteInstallment(installmentId);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '归档分期',
targetId: installmentId,
targetType: 'deposit-installment',
detail: `归档分期${installmentId}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档分期', targetId: installmentId, targetType: 'deposit-installment', detail: `归档分期${installmentId}`,
});
return result;
}
@@ -196,48 +141,46 @@ export class DepositsController {
@Put(':id/refund')
@RequirePermission('deposit:refund')
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(id, dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '退还押金',
targetId: id,
targetType: 'deposit',
detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
});
// Send deposit_refunded notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: 'deposit_refunded',
title: '押金已退还',
content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
});
}
} catch (_) { /* don't block response */ }
await this.notifyDeposit(result.studentId, 'deposit_refunded', '押金已退还', `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`);
return result;
}
private async notifyDeposit(
studentId: number,
type: 'deposit_due' | 'deposit_refunded',
title: string,
content: string,
): Promise<void> {
try {
const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (student?.userId) {
void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });
}
} catch {
// 通知失败不影响主流程
}
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id', ParseIntPipe) id: number, @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: 'deposit',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('deposit:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
});
return result;
}

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { DepositsController } from './deposits.controller';
describe('DepositsController purge route', () => {
it('requires deposit:purge on permanent delete route', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, DepositsController.prototype.purge)).toEqual([
'deposit:purge',
]);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除押金(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new DepositsController(
service as never,
{ log } as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '押金管理', action: '永久删除押金', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,65 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
describe('DepositsService.purge', () => {
const createService = (overrides?: { deposit?: Record<string, unknown> }) => {
const deposit = {
id: 1,
studentId: 2,
amount: 500,
status: 'archived',
refundAmount: null,
deductionAmount: 0,
...overrides?.deposit,
};
const repo = {
findOne: jest.fn().mockResolvedValue(deposit),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const installmentRepo = { count: jest.fn().mockResolvedValue(0) };
const service = new DepositsService(
repo as never,
installmentRepo as never,
{} as never,
);
return { service, repo, installmentRepo };
};
it('rejects deposits that are not archived', async () => {
const { service, repo } = createService({ deposit: { status: 'paid' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档押金可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with refund or deduction amounts', async () => {
const withRefund = createService({ deposit: { refundAmount: 100 } });
await expect(withRefund.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有退款金额,无法永久删除'),
);
const withDeduction = createService({ deposit: { deductionAmount: 50 } });
await expect(withDeduction.service.purge(1)).rejects.toThrow(
new BadRequestException('该押金已有抵扣金额,无法永久删除'),
);
expect(withDeduction.repo.delete).not.toHaveBeenCalled();
});
it('rejects deposits with paid installments', async () => {
const { service, installmentRepo, repo } = createService();
installmentRepo.count.mockResolvedValue(1);
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该押金存在已支付分期,无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived deposit with no paid history', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({
message: '已永久删除押金(不可恢复)',
});
expect(repo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -67,16 +67,21 @@ export class DepositsService {
.leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', {
archived: 'archived',
})
.select('student.id', 'studentId')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('room.id', 'roomId')
.addSelect('room.roomNumber', 'roomNumber')
.addSelect('room.building', 'building')
.addSelect('room.roomType', 'roomType')
.addSelect('room.capacity', 'capacity')
.addSelect('deposit.amount', 'depositAmount')
.where('o.status = :activeStatus', { activeStatus: 'active' })
.select('student.id', 'studentId');
const eligibleSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['room.id', 'roomId'],
['room.roomNumber', 'roomNumber'],
['room.building', 'building'],
['room.roomType', 'roomType'],
['room.capacity', 'capacity'],
['deposit.amount', 'depositAmount'],
] as const;
for (const [column, alias] of eligibleSelects) {
qb.addSelect(column, alias);
}
qb.where('o.status = :activeStatus', { activeStatus: 'active' })
.andWhere('o.checkOutDate IS NULL')
.andWhere('student.status = :studentStatus', { studentStatus: 'active' })
.orderBy('room.building', 'ASC')
@@ -167,15 +172,20 @@ export class DepositsService {
const qb = this.repo
.createQueryBuilder('d')
.leftJoin('d.student', 'student')
.select('d.id', 'id')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.addSelect('d.amount', 'amount')
.addSelect('d.status', 'status')
.addSelect('d.paidDate', 'paidDate')
.addSelect('d.refundAmount', 'refundAmount')
.addSelect('d.refundDate', 'refundDate')
.where('d.status != :archived', { archived: 'archived' });
.select('d.id', 'id');
const depositSelects = [
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
['d.amount', 'amount'],
['d.status', 'status'],
['d.paidDate', 'paidDate'],
['d.refundAmount', 'refundAmount'],
['d.refundDate', 'refundDate'],
] as const;
for (const [column, alias] of depositSelects) {
qb.addSelect(column, alias);
}
qb.where('d.status != :archived', { archived: 'archived' });
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
@@ -226,8 +236,8 @@ export class DepositsService {
existing.paidDate = dto.paidDate;
existing.status = 'paid';
existing.recordedBy = userId ?? null;
existing.refundDate = null as unknown as string;
existing.refundAmount = null as unknown as number;
existing.refundDate = null;
existing.refundAmount = null;
existing.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes;
@@ -309,6 +319,28 @@ export class DepositsService {
return { message: '已归档' };
}
async purge(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'archived') {
throw new BadRequestException('仅已归档押金可以永久删除,请先归档');
}
if (Number(deposit.refundAmount || 0) > 0) {
throw new BadRequestException('该押金已有退款金额,无法永久删除');
}
if (Number(deposit.deductionAmount || 0) > 0) {
throw new BadRequestException('该押金已有抵扣金额,无法永久删除');
}
const paidInstallments = await this.installmentRepo.count({
where: { depositId: id, status: 'paid' },
});
if (paidInstallments > 0) {
throw new BadRequestException('该押金存在已支付分期,无法永久删除');
}
await this.repo.delete(id);
return { message: '已永久删除押金(不可恢复)' };
}
async getStats() {
const qb = this.repo
.createQueryBuilder('d')