feat: 支持批量上传关卡
This commit is contained in:
81
lib/cos-client.ts
Normal file
81
lib/cos-client.ts
Normal 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
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