fix(correctness): 并发/事务/实体/时区/状态一致性修复

由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
2026-08-09 21:29:54 +08:00
parent 99ea931409
commit f50301148d
54 changed files with 3843 additions and 722 deletions

View File

@@ -8,6 +8,7 @@ import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { Room } from '../entities/room.entity';
import { escapeLike } from '../common/like-escape';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -30,7 +31,7 @@ export class WalletsService {
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${query.keyword}%`,
keyword: `%${escapeLike(query.keyword)}%`,
});
}
if (query?.roomType) {
@@ -99,7 +100,12 @@ export class WalletsService {
}
async findTransactions(studentId: number) {
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
// 只返回最近 200 条流水,避免无分页全量返回拖垮接口
return this.transactionRepo.find({
where: { studentId },
order: { createdAt: 'DESC' },
take: 200,
});
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
@@ -163,6 +169,11 @@ export class WalletsService {
const uniqueStudentIds = Array.from(new Set(batch.studentIds));
const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
for (const studentId of uniqueStudentIds) {
// operation_id 为 varchar(64)operationId 最长 64直接拼 :studentId 会溢出。
// 截断 operationId 前缀并整体兜底截到 64保证不超长且尽量保留 :studentId。
const opKey = operationId
? `${operationId.slice(0, 50)}:${studentId}`.slice(0, 64)
: undefined;
results.push(
await this.changeBalanceOnce(
{
@@ -172,7 +183,7 @@ export class WalletsService {
description: batch.description,
},
recordedBy,
operationId ? `${operationId}:${studentId}` : undefined,
opKey,
manager,
),
);
@@ -206,11 +217,24 @@ export class WalletsService {
return manager.save(bill);
}
// 原子扣款:由数据库在同一 UPDATE 中扣减并校验余额,避免并发下 check-then-act 导致 double-spend
const debit = await manager
.createQueryBuilder()
.update(StudentWallet)
.set({ balance: () => 'balance - :amount' })
.setParameter('amount', amount)
.where('id = :id', { id: wallet.id })
.andWhere('balance >= :amount', { amount })
.execute();
if (debit.affected !== 1) {
throw new BadRequestException('余额不足');
}
// balanceAfter 为事务内估算值(内存旧值 ± amount原子 UPDATE 已由数据库完成,
// 并发下流水余额可能与最终 DB 余额略有偏差,但金额本身由数据库条件更新保证一致。
wallet.balance = money(Number(wallet.balance) - amount);
bill.paidAmount = money(paid + amount);
bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
await manager.save(wallet);
await manager.save(bill);
await manager.save(
manager.create(WalletTransaction, {
@@ -227,13 +251,38 @@ export class WalletsService {
}
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
if (bill.status === 'cancelled') return bill;
// 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。
// 条件 UPDATE 与调用方事务内的悲观锁是双保险,并发重复退款不会重复冲正。
const claimed = await manager
.createQueryBuilder()
.update(Bill)
.set({ status: 'cancelled', cancelledAt: () => 'NOW()', cancelReason: reason })
.where('id = :id', { id: bill.id })
.andWhere('status <> :cancelled', { cancelled: 'cancelled' })
.execute();
if (claimed.affected !== 1) return bill; // 已被其他请求取消,直接返回(幂等)
bill.status = 'cancelled';
bill.cancelledAt = new Date();
bill.cancelReason = reason;
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
if (paid > 0) {
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
// 原子退款:余额累加由数据库完成,避免并发覆盖
const credit = await manager
.createQueryBuilder()
.update(StudentWallet)
.set({ balance: () => 'balance + :amount' })
.setParameter('amount', paid)
.where('id = :id', { id: wallet.id })
.execute();
if (credit.affected !== 1) {
throw new BadRequestException('学生钱包不存在,退款失败');
}
// balanceAfter 为事务内估算值(内存旧值 + paid原子 UPDATE 已由数据库完成,
// 并发下流水余额可能与最终 DB 余额略有偏差,但冲正金额本身由数据库累加保证一致。
wallet.balance = money(Number(wallet.balance) + paid);
await manager.save(wallet);
await manager.save(
manager.create(WalletTransaction, {
studentId: bill.studentId,
@@ -246,11 +295,8 @@ export class WalletsService {
}),
);
}
bill.status = 'cancelled';
bill.paidAmount = 0;
bill.outstandingAmount = 0;
bill.cancelledAt = new Date();
bill.cancelReason = reason;
return manager.save(bill);
}
@@ -269,9 +315,14 @@ export class WalletsService {
.getMany();
const settled: Bill[] = [];
for (const bill of bills) {
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
if (!wallet || money(wallet.balance) <= 0) break;
settled.push(await this.debitBill(manager, bill, recordedBy));
const paidBefore = money(bill.paidAmount);
const result = await this.debitBill(manager, bill, recordedBy);
if (money(result.paidAmount) > paidBefore) {
settled.push(result);
} else {
// 余额已不足以支付后续账单,停止结算
break;
}
}
return settled;
}