feat: 支持批量上传关卡
This commit is contained in:
144
lib/sort-key.ts
Normal file
144
lib/sort-key.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { generateKeyBetween } from 'fractional-indexing'
|
||||
import { prisma } from './prisma'
|
||||
|
||||
/**
|
||||
* fractional-indexing 要求按字节序比较 sortKey。
|
||||
* MySQL 默认 collation(utf8mb4_0900_ai_ci / utf8mb4_unicode_ci)大小写不敏感,
|
||||
* 一旦出现大写字母的 key(例如 `Zz`,它在 fractional-indexing 里 < `a0`),
|
||||
* MySQL ORDER BY 就会给出和 JS 不一致的顺序。
|
||||
*
|
||||
* 结论:**sortKey 的排序永远在 JS 里做**,不要依赖 DB 的 ORDER BY。
|
||||
* 这个比较函数就是 JS 原生的字符串 `<` / `>`,对 ASCII 等价于字节序。
|
||||
*/
|
||||
export function compareSortKey(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 sortKey 顺序返回所有关卡(仅取指定字段),排序在 JS 里完成。
|
||||
* tiebreaker:createdAt 升序(仅用于 sortKey 意外重复时的确定性)。
|
||||
*/
|
||||
export async function listLevelsOrderedBySortKey<
|
||||
T extends Record<string, unknown>
|
||||
>(args: {
|
||||
select: Record<string, boolean>
|
||||
excludeId?: string | null
|
||||
}): Promise<Array<T & { sortKey: string; createdAt: Date }>> {
|
||||
const rows = await prisma.level.findMany({
|
||||
where: args.excludeId ? { id: { not: args.excludeId } } : undefined,
|
||||
select: {
|
||||
...args.select,
|
||||
sortKey: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
return (rows as Array<T & { sortKey: string; createdAt: Date }>).sort(
|
||||
(a, b) => {
|
||||
const c = compareSortKey(a.sortKey, b.sortKey)
|
||||
if (c !== 0) return c
|
||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算追加到末尾的 sortKey:取当前最大 sortKey,生成一个比它更大的。
|
||||
* 空表场景返回 fractional-indexing 库定义的初始 key。
|
||||
*
|
||||
* 注意:不能用 `orderBy: { sortKey: 'desc' } findFirst`,因为 MySQL 大小写不敏感
|
||||
* collation 会错把 `a0` 当成最大,实际 `z...` 才是最大。这里只能拉所有 key 后在 JS 排。
|
||||
*/
|
||||
export async function keyAfterLast(): Promise<string> {
|
||||
const rows = await prisma.level.findMany({ select: { sortKey: true } })
|
||||
if (rows.length === 0) {
|
||||
return generateKeyBetween(null, null)
|
||||
}
|
||||
let maxKey = rows[0].sortKey
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
if (compareSortKey(rows[i].sortKey, maxKey) > 0) maxKey = rows[i].sortKey
|
||||
}
|
||||
return generateKeyBetween(maxKey, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 prevKey / nextKey 计算中间 key;两端任一可为 null(表示头/尾)。
|
||||
*/
|
||||
export function keyBetween(
|
||||
prevKey: string | null,
|
||||
nextKey: string | null
|
||||
): string {
|
||||
return generateKeyBetween(prevKey, nextKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量重平衡:按当前 JS 侧排序给所有行重新分配递增 key。
|
||||
*
|
||||
* 触发场景:
|
||||
* - 目标位置两侧 key 相等或倒置
|
||||
* - 手动修复历史 Z 系列 key 带来的 DB/JS 排序分歧
|
||||
*/
|
||||
export async function rebalanceAllKeys(): Promise<number> {
|
||||
const rows = await prisma.level.findMany({
|
||||
select: { id: true, sortKey: true, sortOrder: true, createdAt: true },
|
||||
})
|
||||
// JS 侧排序:sortKey 字节序 > sortOrder > createdAt
|
||||
rows.sort((a, b) => {
|
||||
const c = compareSortKey(a.sortKey, b.sortKey)
|
||||
if (c !== 0) return c
|
||||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
||||
})
|
||||
|
||||
let prev: string | null = null
|
||||
for (const r of rows) {
|
||||
const key = generateKeyBetween(prev, null)
|
||||
await prisma.level.update({ where: { id: r.id }, data: { sortKey: key } })
|
||||
prev = key
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* position 是 1-based。
|
||||
* - 创建场景:position 合法范围 [1, total + 1]
|
||||
* - 编辑场景:position 合法范围 [1, total](total 已含当前行,调用方需传 excludeId 排除自己)
|
||||
*
|
||||
* 排序全部在 JS 里做,避免 MySQL collation 导致的 sortKey 顺序错乱。
|
||||
*/
|
||||
export async function keyForPosition(
|
||||
position: number,
|
||||
excludeId?: string | null
|
||||
): Promise<string> {
|
||||
const rows = await listLevelsOrderedBySortKey<{ id: string }>({
|
||||
select: { id: true },
|
||||
excludeId,
|
||||
})
|
||||
const prevRow = rows[position - 2] // position=1 时 prev 为 undefined(头)
|
||||
const nextRow = rows[position - 1] // position=rows.length+1 时 next 为 undefined(尾)
|
||||
|
||||
const prevKey = prevRow?.sortKey ?? null
|
||||
const nextKey = nextRow?.sortKey ?? null
|
||||
|
||||
if (canComputeBetween(prevKey, nextKey)) {
|
||||
return generateKeyBetween(prevKey, nextKey)
|
||||
}
|
||||
|
||||
// 兜底:重平衡后再试一次
|
||||
await rebalanceAllKeys()
|
||||
const retryRows = await listLevelsOrderedBySortKey<{ id: string }>({
|
||||
select: { id: true },
|
||||
excludeId,
|
||||
})
|
||||
const retryPrev = retryRows[position - 2]?.sortKey ?? null
|
||||
const retryNext = retryRows[position - 1]?.sortKey ?? null
|
||||
return generateKeyBetween(retryPrev, retryNext)
|
||||
}
|
||||
|
||||
function canComputeBetween(
|
||||
prevKey: string | null,
|
||||
nextKey: string | null
|
||||
): boolean {
|
||||
if (prevKey === null && nextKey === null) return true
|
||||
if (prevKey === null || nextKey === null) return true
|
||||
return compareSortKey(prevKey, nextKey) < 0
|
||||
}
|
||||
Reference in New Issue
Block a user