fix: 优化关卡排序

This commit is contained in:
richarjiang
2026-05-03 22:27:16 +08:00
parent 1f8db6c473
commit 9185df3567
12 changed files with 439 additions and 104 deletions

View File

@@ -0,0 +1,8 @@
-- Description: Add fractional-indexing sort key for level ordering.
-- Sort by this field in application code, not with MySQL ORDER BY, because the
-- default utf8mb4 collation is case-insensitive and can misorder keys.
ALTER TABLE levels
ADD COLUMN sort_key VARCHAR(64) NOT NULL DEFAULT 'a0' AFTER hint3;
CREATE INDEX levels_sort_key_idx ON levels (sort_key);

View File

@@ -28,7 +28,7 @@ export class LevelController {
@ApiOperation({
summary: '获取已通关关卡列表',
description:
'返回当前用户所有已通关的关卡按关卡顺序sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
'返回当前用户所有已通关的关卡按关卡顺序sortKey 字节序)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
})
@ApiResponse({ status: 200, description: '成功' })
@ApiResponse({ status: 401, description: '未授权' })

View File

@@ -130,7 +130,7 @@ export class LevelService {
}
/**
* 获取用户已通关的关卡列表按关卡顺序sortOrder)升序返回
* 获取用户已通关的关卡列表按关卡顺序sortKey 字节序)升序返回
*/
async getCompletedLevels(userId: string): Promise<CompletedLevelDto[]> {
const progressList =

View File

@@ -16,7 +16,7 @@ export function toNextLevelDto(level: Level): NextLevelDto {
}
/**
* Given all levels (sorted by sortOrder ASC) and the set of completed level IDs,
* Given all levels (sorted by sortKey byte order) and the set of completed level IDs,
* return the next `count` uncompleted levels.
*/
export function findNextUncompletedLevels(

View File

@@ -30,6 +30,7 @@ describe('ShareService', () => {
hint1: `提示${i + 1}`,
hint2: null,
hint3: null,
sortKey: `a${i}`,
sortOrder: i,
timeLimit: i === 0 ? 60 : null,
createdAt: new Date('2026-01-01'),

View File

@@ -48,6 +48,9 @@ export class Level {
@Column({ type: 'varchar', length: 191, nullable: true })
hint3!: string | null;
@Column({ type: 'varchar', length: 64, name: 'sort_key', default: 'a0' })
sortKey!: string;
@Column({ type: 'int', name: 'sort_order', default: 0 })
sortOrder!: number;

View File

@@ -0,0 +1,74 @@
import { Repository } from 'typeorm';
import { Level } from '../entities/level.entity';
import { compareSortKey, LevelRepository } from './level.repository';
describe('LevelRepository', () => {
const makeLevel = (
id: string,
sortKey: string,
sortOrder: number,
createdAt: string,
): Level =>
({
id,
image1Url: `https://example.com/${id}_1.png`,
image1Description: null,
image2Url: `https://example.com/${id}_2.png`,
image2Description: null,
answer: id,
punchline: null,
hint1: null,
hint2: null,
hint3: null,
sortKey,
sortOrder,
timeLimit: null,
createdAt: new Date(createdAt),
updatedAt: new Date(createdAt),
}) as Level;
it('compares sortKey with JS byte-order semantics', () => {
expect(compareSortKey('Zz', 'a0')).toBeLessThan(0);
expect(compareSortKey('a0', 'Zz')).toBeGreaterThan(0);
expect(compareSortKey('a0', 'a0')).toBe(0);
});
it('orders levels by sortKey in application code and normalizes sortOrder', async () => {
const rows = [
makeLevel('middle', 'a0', 10, '2026-01-02T00:00:00.000Z'),
makeLevel('first', 'Zz', 20, '2026-01-03T00:00:00.000Z'),
makeLevel('last', 'z0', 30, '2026-01-01T00:00:00.000Z'),
];
const repository = new LevelRepository({
find: jest.fn().mockResolvedValue(rows),
} as unknown as Repository<Level>);
const result = await repository.findAllOrdered();
expect(result.map((level) => level.id)).toEqual([
'first',
'middle',
'last',
]);
expect(result.map((level) => level.sortOrder)).toEqual([0, 1, 2]);
});
it('uses sortOrder and createdAt as deterministic tie breakers', async () => {
const rows = [
makeLevel('third', 'a0', 2, '2026-01-01T00:00:00.000Z'),
makeLevel('second', 'a0', 1, '2026-01-02T00:00:00.000Z'),
makeLevel('first', 'a0', 1, '2026-01-01T00:00:00.000Z'),
];
const repository = new LevelRepository({
find: jest.fn().mockResolvedValue(rows),
} as unknown as Repository<Level>);
const result = await repository.findAllOrdered();
expect(result.map((level) => level.id)).toEqual([
'first',
'second',
'third',
]);
});
});

View File

@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { Level } from '../entities/level.entity';
import { ILevelRepository } from './level.repository.interface';
export function compareSortKey(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
@Injectable()
export class LevelRepository implements ILevelRepository {
constructor(
@@ -12,21 +16,39 @@ export class LevelRepository implements ILevelRepository {
) {}
async findAll(): Promise<Level[]> {
return this.repository.find();
return this.findAllOrdered();
}
async findById(id: string): Promise<Level | null> {
return this.repository.findOne({ where: { id } });
const levels = await this.findAllOrdered();
return levels.find((level) => level.id === id) ?? null;
}
async findByIds(ids: string[]): Promise<Level[]> {
if (ids.length === 0) return [];
return this.repository.find({ where: { id: In(ids) } });
const idSet = new Set(ids);
const levels = await this.findAllOrdered();
return levels.filter((level) => idSet.has(level.id));
}
async findAllOrdered(): Promise<Level[]> {
return this.repository.find({
order: { sortOrder: 'ASC' },
});
const levels = await this.repository.find();
return this.orderBySortKey(levels);
}
private orderBySortKey(levels: Level[]): Level[] {
return levels
.slice()
.sort((a, b) => {
const sortKeyCompare = compareSortKey(a.sortKey, b.sortKey);
if (sortKeyCompare !== 0) return sortKeyCompare;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
return a.createdAt.getTime() - b.createdAt.getTime();
})
.map((level, index) =>
Object.assign(Object.create(Object.getPrototypeOf(level)), level, {
sortOrder: index,
}),
);
}
}