Files
gongxue-base/apps/server/src/wallets/wallets.service.ts
wangziqi f50301148d 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)
2026-08-09 21:29:54 +08:00

355 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository, In } from 'typeorm';
import { Bill } from '../entities/bill.entity';
import { Student } from '../entities/student.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
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));
@Injectable()
export class WalletsService {
constructor(
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
@InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private dataSource: DataSource,
private financialOperations?: FinancialOperationsService,
) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean; roomType?: string }) {
const qb = this.studentRepo
.createQueryBuilder('student')
.leftJoin('student.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.leftJoin('occupancy.room', 'room')
.where('student.status = :status', { status: 'active' });
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${escapeLike(query.keyword)}%`,
});
}
if (query?.roomType) {
qb.andWhere('room.roomType = :roomType', { roomType: query.roomType });
}
const rows = await qb
.select([
'student.id AS studentId',
'student.name AS studentName',
'student.studentNo AS studentNo',
'room.roomType AS roomType',
'room.roomNumber AS roomNumber',
])
.orderBy('student.name', 'ASC')
.getRawMany<{
studentId: number;
studentName: string;
studentNo: string | null;
roomType: string | null;
roomNumber: string | null;
}>();
if (!rows.length) return [];
const ids = rows.map((row) => Number(row.studentId));
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
const bills = await this.dataSource
.getRepository(Bill)
.createQueryBuilder('bill')
.select('bill.studentId', 'studentId')
.addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount')
.where('bill.studentId IN (:...ids)', { ids })
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
.groupBy('bill.studentId')
.getRawMany<{ studentId: number; outstandingAmount: string }>();
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
const debtMap = new Map(
bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]),
);
return rows
.map((row) => ({
studentId: Number(row.studentId),
studentName: row.studentName,
studentNo: row.studentNo || undefined,
roomType: row.roomType || undefined,
roomNumber: row.roomNumber || undefined,
balance: money(walletMap.get(Number(row.studentId))?.balance),
outstandingAmount: debtMap.get(Number(row.studentId)) || 0,
}))
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
}
async findRoomTypes() {
const rows = await this.dataSource
.getRepository(Room)
.createQueryBuilder('room')
.innerJoin('room.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.innerJoin('occupancy.student', 'student', 'student.status = :status', { status: 'active' })
.select('room.roomType', 'roomType')
.where('room.roomType IS NOT NULL')
.andWhere("room.roomType <> ''")
.distinct(true)
.orderBy('room.roomType', 'ASC')
.getRawMany<{ roomType: string }>();
return rows.map((row) => row.roomType);
}
async findTransactions(studentId: number) {
// 只返回最近 200 条流水,避免无分页全量返回拖垮接口
return this.transactionRepo.find({
where: { studentId },
order: { createdAt: 'DESC' },
take: 200,
});
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
const { operationId, ...change } = dto;
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.change_balance', () =>
this.changeBalanceOnce(change, recordedBy, operationId),
)
: this.changeBalanceOnce(change, recordedBy, operationId);
}
private async changeBalanceOnce(
dto: Omit<ChangeWalletBalanceDto, 'operationId'>,
recordedBy?: number,
operationId?: string,
transactionManager?: EntityManager,
) {
const amount = money(dto.amount);
if (
!Number.isFinite(dto.amount) ||
Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8
) {
throw new BadRequestException('调账金额最多保留两位小数');
}
if (amount === 0) throw new BadRequestException('调账金额不能为 0');
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const work = async (manager: EntityManager) => {
const wallet = await this.getOrCreateWallet(manager, dto.studentId, true);
const nextBalance = money(Number(wallet.balance) + amount);
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
wallet.balance = nextBalance;
await manager.save(wallet);
await manager.save(
manager.create(WalletTransaction, {
studentId: dto.studentId,
billId: null,
operationId: operationId ?? null,
type: dto.type,
amount,
balanceAfter: nextBalance,
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
recordedBy: recordedBy || null,
}),
);
const payments =
amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, {
studentId: dto.studentId,
});
return { wallet: finalWallet, payments };
};
return transactionManager ? work(transactionManager) : this.dataSource.transaction(work);
}
async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) {
const { operationId, ...batch } = dto;
const work = () =>
this.dataSource.transaction(async (manager) => {
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(
{
studentId,
amount: batch.amount,
type: batch.type,
description: batch.description,
},
recordedBy,
opKey,
manager,
),
);
}
return { count: uniqueStudentIds.length, results };
});
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work)
: work();
}
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
if (bill.status === 'cancelled') return bill;
const total = money(bill.totalAmount);
const paid = Math.max(0, Math.min(money(bill.paidAmount), total));
const remaining = money(Math.max(0, total - paid));
if (remaining <= 0) {
bill.paidAmount = total;
bill.outstandingAmount = 0;
bill.status = 'paid';
return manager.save(bill);
}
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));
if (amount <= 0) {
bill.paidAmount = paid;
bill.outstandingAmount = remaining;
bill.status = paid > 0 ? 'partially_paid' : 'unpaid';
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(bill);
await manager.save(
manager.create(WalletTransaction, {
studentId: bill.studentId,
billId: bill.id,
type: 'bill_payment',
amount: -amount,
balanceAfter: wallet.balance,
description: `账单 #${bill.id} 自动扣款`,
recordedBy: recordedBy ?? null,
}),
);
return bill;
}
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
// 幂等:只有成功把账单从未取消 → 取消的那一次调用才执行退款。
// 条件 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(
manager.create(WalletTransaction, {
studentId: bill.studentId,
billId: bill.id,
type: 'bill_refund',
amount: paid,
balanceAfter: wallet.balance,
description: `取消账单 #${bill.id} 冲正:${reason}`,
recordedBy: recordedBy ?? null,
}),
);
}
bill.paidAmount = 0;
bill.outstandingAmount = 0;
return manager.save(bill);
}
private async settleOutstandingBills(
manager: EntityManager,
studentId: number,
recordedBy?: number,
) {
const bills = await manager
.createQueryBuilder(Bill, 'bill')
.where('bill.studentId = :studentId', { studentId })
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
.andWhere('bill.outstandingAmount > 0')
.orderBy('bill.periodStart', 'ASC')
.addOrderBy('bill.id', 'ASC')
.getMany();
const settled: Bill[] = [];
for (const bill of bills) {
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;
}
private async getOrCreateWallet(manager: EntityManager, studentId: number, lock = false) {
const find = async () => {
if (!lock || !manager.createQueryBuilder) {
return manager.findOne(StudentWallet, { where: { studentId } });
}
let query = manager
.createQueryBuilder(StudentWallet, 'wallet')
.where('wallet.studentId = :studentId', { studentId });
query = query.setLock('pessimistic_write');
return query.getOne();
};
let wallet = await find();
if (!wallet) {
try {
if (manager.insert) await manager.insert(StudentWallet, { studentId, balance: 0 });
else wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
} catch {
// A concurrent request may have inserted the one wallet row.
}
wallet ||= await find();
}
if (!wallet) throw new NotFoundException('学生钱包创建失败');
return wallet;
}
}