This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -3,18 +3,21 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
|
||||
import { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
|
||||
import {
|
||||
ensureDirectories,
|
||||
importSingleFurni,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import type { ImportSingleResult } from "@/types/furni";
|
||||
|
||||
interface BatchItem {
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
id: number;
|
||||
classname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
revision: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,176 +25,199 @@ interface BatchItem {
|
||||
* Processes multiple furniture items with configurable concurrency,
|
||||
* streaming real-time progress events to the client.
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const rawItems: BatchItem[] = body.items || [];
|
||||
const concurrency = Math.min(Math.max(body.concurrency || 3, 1), 5);
|
||||
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
if (rawItems.length === 0) return apiError("No items to import", 400);
|
||||
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
// Deduplicate by (spriteId, classname) to avoid concurrent INSERT races on
|
||||
// items_base.id when the same item is listed twice in the request body.
|
||||
const seen = new Set<string>();
|
||||
const items: BatchItem[] = [];
|
||||
for (const it of rawItems) {
|
||||
const key = `${it.id}:${it.classname}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push(it);
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
const encoder = new TextEncoder();
|
||||
await ensureDirectories();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
const startTime = Date.now();
|
||||
send({ type: "batch_start", total: items.length, concurrency });
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{ entry: Record<string, unknown>; itemType: string }> = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let withWarnings = 0;
|
||||
const furniDataEntries: Array<{
|
||||
entry: Record<string, unknown>;
|
||||
itemType: string;
|
||||
}> = [];
|
||||
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
// Process in chunks of `concurrency`
|
||||
for (let i = 0; i < items.length; i += concurrency) {
|
||||
const chunk = items.slice(i, i + concurrency);
|
||||
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({ type: "item_progress", classname: item.classname, status: "started", index });
|
||||
const promises = chunk.map(async (item, chunkIdx) => {
|
||||
const index = i + chunkIdx;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "started",
|
||||
index,
|
||||
});
|
||||
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({ type: "item_progress", classname: item.classname, status, index });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result: ImportSingleResult = await importSingleFurni({
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
skipFurniDataWrite: true,
|
||||
onProgress: (status: string) => {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status,
|
||||
index,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry) furniDataEntries.push(result.furniDataEntry);
|
||||
if (result.ok) {
|
||||
succeeded++;
|
||||
if (result.warnings.length > 0) withWarnings++;
|
||||
if (result.furniDataEntry)
|
||||
furniDataEntries.push(result.furniDataEntry);
|
||||
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "done",
|
||||
index,
|
||||
itemId: result.itemId,
|
||||
warnings:
|
||||
result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: item.classname,
|
||||
status: "failed",
|
||||
index,
|
||||
message: (err as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Batch write FurnitureData.json once at the end — BEFORE RCON so the
|
||||
// emulator never sees items that the Nitro client can't render.
|
||||
let furniDataWriteOk = true;
|
||||
if (furniDataEntries.length > 0) {
|
||||
try {
|
||||
await appendFurniEntriesBatch(furniDataEntries);
|
||||
} catch (err) {
|
||||
furniDataWriteOk = false;
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_batch_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: `FurnitureData.json batch write failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
// Refresh game server caches once — skip if FurnitureData write failed
|
||||
// to avoid an inconsistent state (emulator sees items, client doesn't).
|
||||
if (furniDataWriteOk) {
|
||||
try {
|
||||
const okCat = await rcon.updateCatalog();
|
||||
const okItems = await rcon.updateItems();
|
||||
if (!okCat || !okItems) {
|
||||
send({
|
||||
type: "item_progress",
|
||||
classname: "_rcon_",
|
||||
status: "failed",
|
||||
index: -1,
|
||||
message: "RCON refresh failed — emulator cache may be stale",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-furni] RCON update failed after batch:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user