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, @InjectRepository(WalletTransaction) private transactionRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, 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, 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; } }