feat: 批量导入支持记录三方 id,实现增量更新,存量覆盖

This commit is contained in:
richarjiang
2026-05-12 10:26:08 +08:00
parent e2fd091b4e
commit e0b88e68e9
5 changed files with 127 additions and 53 deletions

View File

@@ -104,7 +104,21 @@ COS_APPID=
<claude-mem-context>
# Memory Context
# $CMEM MemeStudio 2026-05-01 9:31am GMT+8
# [MemeStudio] recent context, 2026-05-12 10:21am GMT+8
No previous sessions found.
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖decision 🚨security_alert 🔐security_note
Format: ID TIME TYPE TITLE
Fetch details: get_observations([IDs]) | Search: mem-search skill
Stats: 6 obs (1,857t read) | 145,103t work | 99% savings
### May 12, 2026
1517 10:14a 🟣 Batch import logic upgraded to upsert
1518 " 🔵 MemeStudio project context loaded
1519 10:15a 🔵 Level schema lacks riddle_id for upsert deduplication
1520 " 🔵 Batch import always POSTs, never checks for existing riddle_id
1521 10:16a 🔵 API route patch failed - upsert logic not applied
1522 10:17a ✅ Schema, types, and UI updated for riddle_id upsert
Access 145k tokens of past work via get_observations([IDs]) or mem-search skill.
</claude-mem-context>

View File

@@ -56,6 +56,38 @@ function parsePosition(raw: unknown): number | undefined {
return n
}
function parseRiddleId(raw: unknown): string | null {
if (raw === undefined || raw === null || raw === '') return null
if (typeof raw !== 'string') {
throw new Error('riddleId 必须是字符串')
}
const riddleId = raw.trim()
if (!riddleId) return null
if (riddleId.length > 128) {
throw new Error('riddleId 长度不能超过 128')
}
return riddleId
}
function optionalString(raw: unknown): string | null {
return raw ? String(raw) : null
}
function buildLevelData(body: Record<string, unknown>) {
return {
image1Url: body.image1Url as string,
image1Description: optionalString(body.image1Description),
image2Url: body.image2Url as string,
image2Description: optionalString(body.image2Description),
answer: body.answer as string,
punchline: optionalString(body.punchline),
hint1: optionalString(body.hint1),
hint2: optionalString(body.hint2),
hint3: optionalString(body.hint3),
}
}
// POST /api/levels - Create a new level
// body.position 可选1..N+1不传则追加末尾
export async function POST(request: NextRequest) {
@@ -69,17 +101,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json()
const {
image1Url,
image1Description,
image2Url,
image2Description,
answer,
punchline,
hint1,
hint2,
hint3,
} = body
const { image1Url, image2Url, answer } = body
if (!image1Url || !image2Url || !answer) {
return NextResponse.json(
@@ -89,15 +111,49 @@ export async function POST(request: NextRequest) {
}
let position: number | undefined
let riddleId: string | null
try {
position = parsePosition(body.position)
riddleId = parseRiddleId(body.riddleId)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'invalid position' },
{ error: e instanceof Error ? e.message : 'invalid level payload' },
{ status: 400 }
)
}
const data = buildLevelData(body)
if (riddleId) {
const existing = await prisma.level.findUnique({
where: { riddleId },
})
if (existing) {
let newSortKey: string | undefined
if (position !== undefined) {
const total = await prisma.level.count()
if (position > total) {
return NextResponse.json(
{ error: `position 超出范围,合法区间 [1, ${total}]` },
{ status: 400 }
)
}
newSortKey = await keyForPosition(position, existing.id)
}
const level = await prisma.level.update({
where: { id: existing.id },
data: {
...data,
...(newSortKey ? { sortKey: newSortKey } : {}),
},
})
return NextResponse.json(level)
}
}
let sortKey: string
if (position === undefined) {
sortKey = await keyAfterLast()
@@ -115,15 +171,8 @@ export async function POST(request: NextRequest) {
const level = await prisma.level.create({
data: {
id: uuidv4(),
image1Url,
image1Description: image1Description || null,
image2Url,
image2Description: image2Description || null,
answer,
punchline: punchline || null,
hint1: hint1 || null,
hint2: hint2 || null,
hint3: hint3 || null,
riddleId,
...data,
sortKey,
},
})
@@ -151,18 +200,7 @@ export async function PUT(request: NextRequest) {
}
const body = await request.json()
const {
id,
image1Url,
image1Description,
image2Url,
image2Description,
answer,
punchline,
hint1,
hint2,
hint3,
} = body
const { id } = body
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
@@ -191,18 +229,12 @@ export async function PUT(request: NextRequest) {
newSortKey = await keyForPosition(position, id)
}
const data = buildLevelData(body)
const level = await prisma.level.update({
where: { id },
data: {
image1Url,
image1Description: image1Description || null,
image2Url,
image2Description: image2Description || null,
answer,
punchline: punchline || null,
hint1: hint1 || null,
hint2: hint2 || null,
hint3: hint3 || null,
...data,
...(newSortKey ? { sortKey: newSortKey } : {}),
},
})

View File

@@ -29,6 +29,7 @@ type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error'
interface ParsedItem {
id: string // riddle_id 或文件夹名,仅用于 React key
riddleId?: string
folderName: string
// 本地文件引用
referenceFile?: File
@@ -114,7 +115,11 @@ function buildFilePathMap(files: FileList): Map<string, FileWithPath> {
}
function validateItem(item: ParsedItem) {
if (!item.answer) {
if (item.riddleId !== undefined && !item.riddleId) {
item.parseError = '未解析到 riddle_id'
} else if (item.riddleId && item.riddleId.length > 128) {
item.parseError = 'riddle_id 长度不能超过 128'
} else if (!item.answer) {
item.parseError = '未解析到 answer'
} else if (item.answer.length > ANSWER_MAX_LENGTH) {
item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.length}`
@@ -123,6 +128,20 @@ function validateItem(item: ParsedItem) {
}
}
function markDuplicateRiddleIds(items: ParsedItem[]) {
const counts = new Map<string, number>()
items.forEach((item) => {
if (!item.riddleId) return
counts.set(item.riddleId, (counts.get(item.riddleId) || 0) + 1)
})
items.forEach((item) => {
if (item.riddleId && counts.get(item.riddleId)! > 1) {
item.parseError = `riddle_id 重复: ${item.riddleId}`
}
})
}
async function parseConfigJson(
jsonFile: FileWithPath,
fileByPath: Map<string, FileWithPath>
@@ -139,16 +158,18 @@ async function parseConfigJson(
const config = raw as RiddleConfigItem
if (!config || typeof config !== 'object') return
const riddleId = (config.riddle_id || '').trim()
const referencePath = config.reference_image || ''
const riddlePath = config.riddle_image || ''
const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath))
const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath))
const id = config.riddle_id || `${jsonFile.name}-${index + 1}`
const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `${index + 1}`
const id = `${riddleId || jsonFile.name}-${index + 1}`
const folderName = riddleId || referencePath.split('/').slice(-2, -1)[0] || `${index + 1}`
const hints = Array.isArray(config.hints) ? config.hints : []
const item: ParsedItem = {
id,
riddleId,
folderName,
referenceFile: reference,
riddleFile: riddle,
@@ -167,6 +188,8 @@ async function parseConfigJson(
items.push(item)
})
markDuplicateRiddleIds(items)
return items
}
@@ -234,6 +257,7 @@ async function parseFolders(files: FileList): Promise<ParsedItem[]> {
const text = await metadata.text()
const json = JSON.parse(text) as RiddleMetadata
const plan = json.plan || {}
item.riddleId = (plan.riddle_id || '').trim() || undefined
item.anchorText = truncate(plan.anchor_text, 500)
item.answer = (plan.answer || '').trim()
item.punchline = (
@@ -375,9 +399,10 @@ export function BatchImportDialog({
uploadToCos(it.riddleFile, it.riddleFile.name),
])
updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' })
updateItem(it.id, { status: 'creating', statusMessage: '导入关卡…' })
const payload = {
...(it.riddleId ? { riddleId: it.riddleId } : {}),
image1Url,
image1Description: it.anchorText || '',
image2Url,
@@ -398,7 +423,7 @@ export function BatchImportDialog({
const err = await res.json().catch(() => ({}))
throw new Error(err.error || `HTTP ${res.status}`)
}
updateItem(it.id, { status: 'done', statusMessage: '已创建' })
updateItem(it.id, { status: 'done', statusMessage: '已导入' })
} catch (e) {
updateItem(it.id, {
status: 'error',
@@ -498,10 +523,10 @@ export function BatchImportDialog({
{isRunning ? (
<>
<Spinner size="sm" className="mr-2" />
</>
) : (
`确认创建 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})`
`确认导入 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})`
)}
</Button>
</DialogFooter>
@@ -531,7 +556,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
case 'done':
return (
<span className="flex items-center gap-1 text-green-600 text-xs">
<CheckCircle2 className="h-4 w-4" />
<CheckCircle2 className="h-4 w-4" /> {item.statusMessage || '已导入'}
</span>
)
case 'error':
@@ -549,7 +574,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
)
}
return (
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-xs"></span>
)
}
})()

View File

@@ -13,6 +13,7 @@ datasource db {
model Level {
id String @id @default(uuid())
riddleId String? @unique @map("riddle_id") @db.VarChar(128)
image1Url String @map("image1_url") @db.VarChar(500)
image1Description String? @map("image1_description") @db.VarChar(500)
image2Url String @map("image2_url") @db.VarChar(500)

View File

@@ -1,5 +1,6 @@
export interface Level {
id: string
riddleId: string | null
image1Url: string
image1Description: string | null
image2Url: string
@@ -16,6 +17,7 @@ export interface Level {
}
export interface LevelFormData {
riddleId?: string
image1Url: string
image1Description?: string
image2Url: string