fix: 优化关卡排序
This commit is contained in:
8
src/database/migrations/004_add_level_sort_key.sql
Normal file
8
src/database/migrations/004_add_level_sort_key.sql
Normal 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);
|
||||
@@ -28,7 +28,7 @@ export class LevelController {
|
||||
@ApiOperation({
|
||||
summary: '获取已通关关卡列表',
|
||||
description:
|
||||
'返回当前用户所有已通关的关卡,按关卡顺序(sortOrder)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
|
||||
'返回当前用户所有已通关的关卡,按关卡顺序(sortKey 字节序)升序排列。每项包含完整关卡信息 + 通关时长 + 通关时间。',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权' })
|
||||
|
||||
@@ -130,7 +130,7 @@ export class LevelService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户已通关的关卡列表,按关卡顺序(sortOrder)升序返回
|
||||
* 获取用户已通关的关卡列表,按关卡顺序(sortKey 字节序)升序返回
|
||||
*/
|
||||
async getCompletedLevels(userId: string): Promise<CompletedLevelDto[]> {
|
||||
const progressList =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user