由 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)
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
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;
|
||
}
|
||
}
|