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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user