Merge pull request '修正换宿当天查寝归属、修复 SQLite 钱包调账锁异常' (#36) from xiongyuxing/gongxue-base:main into main

This commit is contained in:
2026-07-22 08:59:43 +00:00
6 changed files with 99 additions and 18 deletions

View File

@@ -1,13 +1,14 @@
import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { Bed } from '../entities/bed.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { RoomInspection } from '../entities/room-inspection.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { occupancyWhereOnDate } from './room-occupancy-date';
interface InspectorIdentity {
id?: number;
@@ -211,10 +212,7 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
private findOccupanciesForDate(manager: EntityManager, roomId: number, date: string) {
return manager.getRepository(Occupancy).find({
where: [
{ roomId, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
{ roomId, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) },
],
where: occupancyWhereOnDate(date, roomId),
relations: ['student', 'bed'],
order: { id: 'ASC' },
});
@@ -222,10 +220,7 @@ export class RoomInspectionsService implements OnApplicationBootstrap {
private findAllOccupanciesForDate(manager: EntityManager, date: string) {
return manager.getRepository(Occupancy).find({
where: [
{ checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) },
],
where: occupancyWhereOnDate(date),
relations: ['student', 'bed'],
order: { roomId: 'ASC', id: 'ASC' },
});

View File

@@ -0,0 +1,22 @@
import { occupancyWhereOnDate } from './room-occupancy-date';
describe('occupancyWhereOnDate', () => {
it('treats checkout date as the first day no longer occupying the old room', () => {
const where = occupancyWhereOnDate('2026-07-22', 10);
expect(where).toHaveLength(2);
expect(where[0]).toEqual(
expect.objectContaining({ roomId: 10, checkInDate: expect.anything(), checkOutDate: expect.anything() }),
);
expect(where[1]).toEqual(
expect.objectContaining({ roomId: 10, checkInDate: expect.anything(), checkOutDate: expect.anything() }),
);
expect(String((where[1] as any).checkOutDate._type)).toBe('moreThan');
expect((where[1] as any).checkOutDate._value).toBe('2026-07-22');
});
it('can query all rooms for automatic settlement', () => {
const where = occupancyWhereOnDate('2026-07-22');
expect(where.every((condition) => condition.roomId === undefined)).toBe(true);
});
});

View File

@@ -0,0 +1,13 @@
import { FindOptionsWhere, IsNull, LessThanOrEqual, MoreThan } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
export function occupancyWhereOnDate(
date: string,
roomId?: number,
): FindOptionsWhere<Occupancy>[] {
const scope = roomId === undefined ? {} : { roomId };
return [
{ ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
{ ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThan(date) },
];
}

View File

@@ -8,7 +8,6 @@ import {
Not,
In,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
import { Room } from '../entities/room.entity';
@@ -20,6 +19,7 @@ import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto';
import { RoomInspectionsService } from './room-inspections.service';
import { occupancyWhereOnDate } from './room-occupancy-date';
@Injectable()
export class RoomsService {
@@ -246,10 +246,7 @@ export class RoomsService {
});
const occupancies = await this.occRepo.find({
where: [
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
],
where: occupancyWhereOnDate(targetDate),
relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'],
order: { checkInDate: 'ASC' },
});

View File

@@ -117,3 +117,54 @@ describe('WalletsService financial boundaries', () => {
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});
describe('WalletsService wallet locking', () => {
const createQueryManager = () => {
const wallet = { id: 1, studentId: 10, balance: 0 };
const query = {
where: jest.fn(),
setLock: jest.fn(),
getOne: jest.fn(async () => wallet),
};
query.where.mockReturnValue(query);
query.setLock.mockReturnValue(query);
return {
wallet,
query,
manager: {
createQueryBuilder: jest.fn(() => query),
findOne: jest.fn(),
},
};
};
it('skips pessimistic locking for SQLite', async () => {
const ctx = createQueryManager();
const service = new WalletsService(
{} as any,
{} as any,
{} as any,
{ options: { type: 'better-sqlite3' } } as any,
);
const result = await (service as any).getOrCreateWallet(ctx.manager, 10, true);
expect(result).toBe(ctx.wallet);
expect(ctx.query.setLock).not.toHaveBeenCalled();
expect(ctx.query.getOne).toHaveBeenCalled();
});
it('keeps pessimistic write locking for MySQL', async () => {
const ctx = createQueryManager();
const service = new WalletsService(
{} as any,
{} as any,
{} as any,
{ options: { type: 'mysql' } } as any,
);
await (service as any).getOrCreateWallet(ctx.manager, 10, true);
expect(ctx.query.setLock).toHaveBeenCalledWith('pessimistic_write');
});
});

View File

@@ -259,10 +259,13 @@ export class WalletsService {
if (!lock || !manager.createQueryBuilder) {
return manager.findOne(StudentWallet, { where: { studentId } });
}
return manager.createQueryBuilder(StudentWallet, 'wallet')
.where('wallet.studentId = :studentId', { studentId })
.setLock('pessimistic_write')
.getOne();
let query = manager
.createQueryBuilder(StudentWallet, 'wallet')
.where('wallet.studentId = :studentId', { studentId });
if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) {
query = query.setLock('pessimistic_write');
}
return query.getOne();
};
let wallet = await find();
if (!wallet) {