feat: 支持批量上传关卡

This commit is contained in:
richarjiang
2026-05-01 08:44:56 +08:00
parent f3f27def2b
commit 66a9ee2950
27 changed files with 5262 additions and 515 deletions

81
lib/cos-client.ts Normal file
View File

@@ -0,0 +1,81 @@
import COS from 'cos-js-sdk-v5'
import { apiFetch } from '@/lib/api'
interface TempKeyResponse {
credentials: {
tmpSecretId: string
tmpSecretKey: string
sessionToken: string
}
startTime: number
expiredTime: number
bucket: string
region: string
}
let cachedKey: TempKeyResponse | null = null
async function getTempKey(forceRefresh = false): Promise<TempKeyResponse> {
const now = Math.floor(Date.now() / 1000)
// 提前 60 秒失效,避免边界问题
if (!forceRefresh && cachedKey && cachedKey.expiredTime - 60 > now) {
return cachedKey
}
const res = await apiFetch('/api/cos/temp-key')
if (!res.ok) {
throw new Error('获取上传凭证失败')
}
const data = (await res.json()) as TempKeyResponse
cachedKey = data
return data
}
function buildCosClient(key: TempKeyResponse): COS {
return new COS({
getAuthorization: (_options, callback) => {
callback({
TmpSecretId: key.credentials.tmpSecretId,
TmpSecretKey: key.credentials.tmpSecretKey,
SecurityToken: key.credentials.sessionToken,
StartTime: key.startTime,
ExpiredTime: key.expiredTime,
})
},
})
}
function randomFilename(originalName: string): string {
const ext = originalName.split('.').pop() || 'jpg'
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
return `mini_game/images/${timestamp}_${randomStr}.${ext}`
}
/**
* 上传单个文件到腾讯云 COS返回可访问的 URL。
*/
export async function uploadToCos(file: File | Blob, originalName?: string): Promise<string> {
const name = originalName || (file as File).name || 'upload.jpg'
const key = await getTempKey()
const cos = buildCosClient(key)
const filename = randomFilename(name)
return new Promise<string>((resolve, reject) => {
cos.putObject(
{
Bucket: key.bucket,
Region: key.region,
Key: filename,
Body: file as File,
},
(err) => {
if (err) {
reject(new Error(err.message || '上传失败'))
return
}
const url = `https://${key.bucket}.cos.${key.region}.myqcloud.com/${filename}`
resolve(url)
}
)
})
}

144
lib/sort-key.ts Normal file
View File

@@ -0,0 +1,144 @@
import { generateKeyBetween } from 'fractional-indexing'
import { prisma } from './prisma'
/**
* fractional-indexing 要求按字节序比较 sortKey。
* MySQL 默认 collationutf8mb4_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 里完成。
* tiebreakercreatedAt 升序(仅用于 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
}