fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling

- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers
- H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables
- M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps)
- M2: split handleSave try/catch — save errors vs reload errors shown distinctly
- M3: added provider field validation before AI config test request
- Added SSE scoping regression tests (import service + controller)
- Added FK check failure rollback test (database-migrations.spec)
- Updated controller spec expectations for userId parameter

Co-authored-by: Code Review <branch-review>
This commit is contained in:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -47,12 +47,6 @@ export class DepositsController {
});
}
@Get('pending-refunds')
@RequirePermission('deposit:edit')
findPendingRefunds() {
return this.service.findPendingRefunds();
}
@Get('stats')
@RequirePermission('deposit:view')
getStats() {
@@ -165,7 +159,7 @@ export class DepositsController {
}
@Put(':id/refund')
@RequirePermission('deposit:edit')
@RequirePermission('deposit:refund')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id);
@@ -195,67 +189,6 @@ export class DepositsController {
return result;
}
@Post(':id/request-refund')
@RequirePermission('deposit:edit')
async requestRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.requestRefund(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '申请退款',
targetId: +id,
targetType: 'deposit',
detail: '提交退款申请',
ipAddress,
userAgent,
});
return result;
}
@Put(':id/approve-refund')
@RequirePermission('deposit:approve')
async approveRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.approveRefund(+id, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '审批退款',
targetId: +id,
targetType: 'deposit',
detail: `审批通过 → ${result.refundStatus}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id/reject-refund')
@RequirePermission('deposit:approve')
async rejectRefund(
@Param('id') id: string,
@Body() body: { reason: string },
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.rejectRefund(+id, body.reason, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '驳回退款',
targetId: +id,
targetType: 'deposit',
detail: `驳回原因:${body.reason}`,
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) {

View File

@@ -114,79 +114,13 @@ export class DepositsService {
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
return this.repo.save(deposit);
}
// ---- Refund approval flow ----
async requestRefund(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
if (deposit.refundStatus) throw new BadRequestException('已提交退款申请,请等待审批');
deposit.refundStatus = 'pending';
deposit.refundRequestedAt = new Date();
return this.repo.save(deposit);
}
async approveRefund(id: number, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
const transitions: Record<string, string> = {
pending: 'head_teacher_approved',
head_teacher_approved: 'finance_approved',
finance_approved: 'refunded',
};
const nextStatus = transitions[deposit.refundStatus];
if (!nextStatus) throw new BadRequestException(`无效的退款状态: ${deposit.refundStatus}`);
deposit.refundStatus = nextStatus;
deposit.refundApprovedBy = userId;
deposit.refundStatus = 'refunded';
deposit.refundApprovedBy = userId ?? null as unknown as number;
deposit.refundApprovedAt = new Date();
if (nextStatus === 'refunded') {
deposit.status = 'refunded';
deposit.refundDate = new Date().toISOString().slice(0, 10);
deposit.refundAmount = Number(deposit.amount) - Number(deposit.deductionAmount || 0);
}
return this.repo.save(deposit);
}
async rejectRefund(id: number, reason: string, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
deposit.refundStatus = null as unknown as string;
deposit.refundApprovedBy = userId;
deposit.refundApprovedAt = new Date();
deposit.refundRejectedReason = reason;
return this.repo.save(deposit);
}
async findPendingRefunds() {
return this.repo.find({
where: [
{ refundStatus: 'pending' },
{ refundStatus: 'head_teacher_approved' },
],
relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' },
});
}
async remove(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');