feat: 批量导入支持记录三方 id,实现增量更新,存量覆盖
This commit is contained in:
18
AGENTS.md
18
AGENTS.md
@@ -104,7 +104,21 @@ COS_APPID=
|
|||||||
<claude-mem-context>
|
<claude-mem-context>
|
||||||
# Memory 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>
|
</claude-mem-context>
|
||||||
@@ -56,6 +56,38 @@ function parsePosition(raw: unknown): number | undefined {
|
|||||||
return n
|
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
|
// POST /api/levels - Create a new level
|
||||||
// body.position 可选:1..N+1;不传则追加末尾
|
// body.position 可选:1..N+1;不传则追加末尾
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -69,17 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const { image1Url, image2Url, answer } = body
|
||||||
image1Url,
|
|
||||||
image1Description,
|
|
||||||
image2Url,
|
|
||||||
image2Description,
|
|
||||||
answer,
|
|
||||||
punchline,
|
|
||||||
hint1,
|
|
||||||
hint2,
|
|
||||||
hint3,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!image1Url || !image2Url || !answer) {
|
if (!image1Url || !image2Url || !answer) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -89,15 +111,49 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let position: number | undefined
|
let position: number | undefined
|
||||||
|
let riddleId: string | null
|
||||||
try {
|
try {
|
||||||
position = parsePosition(body.position)
|
position = parsePosition(body.position)
|
||||||
|
riddleId = parseRiddleId(body.riddleId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: e instanceof Error ? e.message : 'invalid position' },
|
{ error: e instanceof Error ? e.message : 'invalid level payload' },
|
||||||
{ status: 400 }
|
{ 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
|
let sortKey: string
|
||||||
if (position === undefined) {
|
if (position === undefined) {
|
||||||
sortKey = await keyAfterLast()
|
sortKey = await keyAfterLast()
|
||||||
@@ -115,15 +171,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const level = await prisma.level.create({
|
const level = await prisma.level.create({
|
||||||
data: {
|
data: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
image1Url,
|
riddleId,
|
||||||
image1Description: image1Description || null,
|
...data,
|
||||||
image2Url,
|
|
||||||
image2Description: image2Description || null,
|
|
||||||
answer,
|
|
||||||
punchline: punchline || null,
|
|
||||||
hint1: hint1 || null,
|
|
||||||
hint2: hint2 || null,
|
|
||||||
hint3: hint3 || null,
|
|
||||||
sortKey,
|
sortKey,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -151,18 +200,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const { id } = body
|
||||||
id,
|
|
||||||
image1Url,
|
|
||||||
image1Description,
|
|
||||||
image2Url,
|
|
||||||
image2Description,
|
|
||||||
answer,
|
|
||||||
punchline,
|
|
||||||
hint1,
|
|
||||||
hint2,
|
|
||||||
hint3,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
||||||
@@ -191,18 +229,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
newSortKey = await keyForPosition(position, id)
|
newSortKey = await keyForPosition(position, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = buildLevelData(body)
|
||||||
|
|
||||||
const level = await prisma.level.update({
|
const level = await prisma.level.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
image1Url,
|
...data,
|
||||||
image1Description: image1Description || null,
|
|
||||||
image2Url,
|
|
||||||
image2Description: image2Description || null,
|
|
||||||
answer,
|
|
||||||
punchline: punchline || null,
|
|
||||||
hint1: hint1 || null,
|
|
||||||
hint2: hint2 || null,
|
|
||||||
hint3: hint3 || null,
|
|
||||||
...(newSortKey ? { sortKey: newSortKey } : {}),
|
...(newSortKey ? { sortKey: newSortKey } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error'
|
|||||||
|
|
||||||
interface ParsedItem {
|
interface ParsedItem {
|
||||||
id: string // riddle_id 或文件夹名,仅用于 React key
|
id: string // riddle_id 或文件夹名,仅用于 React key
|
||||||
|
riddleId?: string
|
||||||
folderName: string
|
folderName: string
|
||||||
// 本地文件引用
|
// 本地文件引用
|
||||||
referenceFile?: File
|
referenceFile?: File
|
||||||
@@ -114,7 +115,11 @@ function buildFilePathMap(files: FileList): Map<string, FileWithPath> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateItem(item: ParsedItem) {
|
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'
|
item.parseError = '未解析到 answer'
|
||||||
} else if (item.answer.length > ANSWER_MAX_LENGTH) {
|
} else if (item.answer.length > ANSWER_MAX_LENGTH) {
|
||||||
item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.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(
|
async function parseConfigJson(
|
||||||
jsonFile: FileWithPath,
|
jsonFile: FileWithPath,
|
||||||
fileByPath: Map<string, FileWithPath>
|
fileByPath: Map<string, FileWithPath>
|
||||||
@@ -139,16 +158,18 @@ async function parseConfigJson(
|
|||||||
const config = raw as RiddleConfigItem
|
const config = raw as RiddleConfigItem
|
||||||
if (!config || typeof config !== 'object') return
|
if (!config || typeof config !== 'object') return
|
||||||
|
|
||||||
|
const riddleId = (config.riddle_id || '').trim()
|
||||||
const referencePath = config.reference_image || ''
|
const referencePath = config.reference_image || ''
|
||||||
const riddlePath = config.riddle_image || ''
|
const riddlePath = config.riddle_image || ''
|
||||||
const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath))
|
const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath))
|
||||||
const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath))
|
const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath))
|
||||||
const id = config.riddle_id || `${jsonFile.name}-${index + 1}`
|
const id = `${riddleId || jsonFile.name}-${index + 1}`
|
||||||
const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关`
|
const folderName = riddleId || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关`
|
||||||
const hints = Array.isArray(config.hints) ? config.hints : []
|
const hints = Array.isArray(config.hints) ? config.hints : []
|
||||||
|
|
||||||
const item: ParsedItem = {
|
const item: ParsedItem = {
|
||||||
id,
|
id,
|
||||||
|
riddleId,
|
||||||
folderName,
|
folderName,
|
||||||
referenceFile: reference,
|
referenceFile: reference,
|
||||||
riddleFile: riddle,
|
riddleFile: riddle,
|
||||||
@@ -167,6 +188,8 @@ async function parseConfigJson(
|
|||||||
items.push(item)
|
items.push(item)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
markDuplicateRiddleIds(items)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +257,7 @@ async function parseFolders(files: FileList): Promise<ParsedItem[]> {
|
|||||||
const text = await metadata.text()
|
const text = await metadata.text()
|
||||||
const json = JSON.parse(text) as RiddleMetadata
|
const json = JSON.parse(text) as RiddleMetadata
|
||||||
const plan = json.plan || {}
|
const plan = json.plan || {}
|
||||||
|
item.riddleId = (plan.riddle_id || '').trim() || undefined
|
||||||
item.anchorText = truncate(plan.anchor_text, 500)
|
item.anchorText = truncate(plan.anchor_text, 500)
|
||||||
item.answer = (plan.answer || '').trim()
|
item.answer = (plan.answer || '').trim()
|
||||||
item.punchline = (
|
item.punchline = (
|
||||||
@@ -375,9 +399,10 @@ export function BatchImportDialog({
|
|||||||
uploadToCos(it.riddleFile, it.riddleFile.name),
|
uploadToCos(it.riddleFile, it.riddleFile.name),
|
||||||
])
|
])
|
||||||
|
|
||||||
updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' })
|
updateItem(it.id, { status: 'creating', statusMessage: '导入关卡…' })
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
...(it.riddleId ? { riddleId: it.riddleId } : {}),
|
||||||
image1Url,
|
image1Url,
|
||||||
image1Description: it.anchorText || '',
|
image1Description: it.anchorText || '',
|
||||||
image2Url,
|
image2Url,
|
||||||
@@ -398,7 +423,7 @@ export function BatchImportDialog({
|
|||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
throw new Error(err.error || `HTTP ${res.status}`)
|
throw new Error(err.error || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
updateItem(it.id, { status: 'done', statusMessage: '已创建' })
|
updateItem(it.id, { status: 'done', statusMessage: '已导入' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateItem(it.id, {
|
updateItem(it.id, {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -498,10 +523,10 @@ export function BatchImportDialog({
|
|||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<>
|
<>
|
||||||
<Spinner size="sm" className="mr-2" />
|
<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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -531,7 +556,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
|
|||||||
case 'done':
|
case 'done':
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 text-green-600 text-xs">
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -549,7 +574,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground text-xs">待创建</span>
|
<span className="text-muted-foreground text-xs">待导入</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ datasource db {
|
|||||||
|
|
||||||
model Level {
|
model Level {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
riddleId String? @unique @map("riddle_id") @db.VarChar(128)
|
||||||
image1Url String @map("image1_url") @db.VarChar(500)
|
image1Url String @map("image1_url") @db.VarChar(500)
|
||||||
image1Description String? @map("image1_description") @db.VarChar(500)
|
image1Description String? @map("image1_description") @db.VarChar(500)
|
||||||
image2Url String @map("image2_url") @db.VarChar(500)
|
image2Url String @map("image2_url") @db.VarChar(500)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface Level {
|
export interface Level {
|
||||||
id: string
|
id: string
|
||||||
|
riddleId: string | null
|
||||||
image1Url: string
|
image1Url: string
|
||||||
image1Description: string | null
|
image1Description: string | null
|
||||||
image2Url: string
|
image2Url: string
|
||||||
@@ -16,6 +17,7 @@ export interface Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelFormData {
|
export interface LevelFormData {
|
||||||
|
riddleId?: string
|
||||||
image1Url: string
|
image1Url: string
|
||||||
image1Description?: string
|
image1Description?: string
|
||||||
image2Url: string
|
image2Url: string
|
||||||
|
|||||||
Reference in New Issue
Block a user