feat : update
This commit is contained in:
+86
-7
@@ -28,6 +28,7 @@ import type {
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
||||
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
|
||||
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
|
||||
import {
|
||||
compareLunarisVersions,
|
||||
lunarisVersionSchema,
|
||||
@@ -100,9 +101,11 @@ interface SyncedCharacter {
|
||||
baseStats: CatalogBaseStat[];
|
||||
}
|
||||
|
||||
async function getJson(url: string): Promise<unknown> {
|
||||
type SyncControl = { checkCancelled: () => Promise<void>; report: (phase: string, completed: number, total: number, message: string) => Promise<void> };
|
||||
|
||||
async function getJson(url: string, signal?: AbortSignal): Promise<unknown> {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: signal ?? AbortSignal.timeout(30_000),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok)
|
||||
@@ -110,9 +113,9 @@ async function getJson(url: string): Promise<unknown> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getOptionalJson(url: string): Promise<unknown | null> {
|
||||
async function getOptionalJson(url: string, signal?: AbortSignal): Promise<unknown | null> {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
signal: signal ?? AbortSignal.timeout(30_000),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
@@ -149,13 +152,16 @@ export async function getLatestLunarisVersion(): Promise<string> {
|
||||
async function mapConcurrent<T, R>(
|
||||
items: T[],
|
||||
task: (item: T) => Promise<R>,
|
||||
control?: SyncControl,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
await control?.checkCancelled();
|
||||
const index = next++;
|
||||
results[index] = await task(items[index]);
|
||||
await control?.report("characters", index + 1, items.length, `ดึงข้อมูลตัวละคร ${index + 1}/${items.length}`);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
@@ -171,12 +177,14 @@ async function uploadAssets(
|
||||
target: string;
|
||||
type?: string;
|
||||
}>,
|
||||
control?: SyncControl,
|
||||
): Promise<Set<string>> {
|
||||
const storage = await getMediaStorage();
|
||||
let next = 0;
|
||||
const missing = new Set<string>();
|
||||
async function worker() {
|
||||
while (next < assets.length) {
|
||||
await control?.checkCancelled();
|
||||
const asset = assets[next++];
|
||||
if (!(await storage.file(asset.target).exists())) {
|
||||
if (asset.body) {
|
||||
@@ -199,6 +207,7 @@ async function uploadAssets(
|
||||
});
|
||||
}
|
||||
}
|
||||
await control?.report("assets", next, assets.length, `ดาวน์โหลดรูปภาพ ${next}/${assets.length}`);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
@@ -207,13 +216,15 @@ async function uploadAssets(
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function syncCatalogVersion(version: string): Promise<void> {
|
||||
async function syncCatalogVersion(version: string, control?: SyncControl): Promise<void> {
|
||||
await control?.report("lists", 0, 4, "กำลังดึงรายการ Catalog จาก Lunaris");
|
||||
const [charactersRaw, weaponsRaw, artifactsRaw, materialsRaw] = await Promise.all([
|
||||
getJson(`${API_ROOT}/${version}/charlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/weaponlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/artifactlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/materiallist.json`),
|
||||
]);
|
||||
await control?.report("lists", 4, 4, "ได้รับรายการ Catalog แล้ว");
|
||||
const characterData = characterSchema.parse(charactersRaw);
|
||||
const weaponData = weaponSchema.parse(weaponsRaw);
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
@@ -280,6 +291,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
baseStats: detail ? baseStatsSnapshot(detail) : [],
|
||||
};
|
||||
},
|
||||
control,
|
||||
);
|
||||
const customCharacters: SyncedCharacter[] = await mapConcurrent(
|
||||
[...customTravelers],
|
||||
@@ -315,6 +327,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
baseStats: baseStatsSnapshot(detail),
|
||||
};
|
||||
},
|
||||
control,
|
||||
);
|
||||
let characters = [...remoteCharacters, ...customCharacters];
|
||||
let weapons = Object.entries(weaponData)
|
||||
@@ -409,7 +422,8 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
const missingAssets = await uploadAssets([...uniqueAssets.values()]);
|
||||
await control?.report("assets", 0, uniqueAssets.size, "กำลังดาวน์โหลดรูปภาพ Catalog");
|
||||
const missingAssets = await uploadAssets([...uniqueAssets.values()], control);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({
|
||||
...item,
|
||||
@@ -498,6 +512,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
),
|
||||
]);
|
||||
|
||||
await control?.report("database", 0, 1, "กำลังบันทึก Catalog");
|
||||
await getDb().transaction(async (tx) => {
|
||||
await tx.delete(catalogCharacters);
|
||||
await tx.delete(catalogMaterials);
|
||||
@@ -549,6 +564,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
})),
|
||||
);
|
||||
});
|
||||
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
|
||||
await storage.write("catalog/current.json", JSON.stringify({ version }), {
|
||||
type: "application/json",
|
||||
acl: "public-read",
|
||||
@@ -557,6 +573,67 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
|
||||
export type CatalogSyncResult = "synced" | "unchanged";
|
||||
|
||||
export type CatalogSyncJob = {
|
||||
id: string;
|
||||
status: "queued" | "running" | "completed" | "unchanged" | "cancelled" | "error";
|
||||
phase: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
message: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const syncJobKey = (id: string) => `${redisEventPrefix()}:catalog-sync-job:${id}`;
|
||||
|
||||
async function writeSyncJob(job: CatalogSyncJob) {
|
||||
const redis = await getRedisClient();
|
||||
await redis.set(syncJobKey(job.id), JSON.stringify(job), "EX", 60 * 60);
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function getCatalogSyncJob(id: string): Promise<CatalogSyncJob | null> {
|
||||
const redis = await getRedisClient();
|
||||
const value = await redis.get(syncJobKey(id));
|
||||
return value ? JSON.parse(value) as CatalogSyncJob : null;
|
||||
}
|
||||
|
||||
export async function cancelCatalogSyncJob(id: string): Promise<CatalogSyncJob | null> {
|
||||
const job = await getCatalogSyncJob(id);
|
||||
if (!job || ["completed", "unchanged", "cancelled", "error"].includes(job.status)) return job;
|
||||
return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" });
|
||||
}
|
||||
|
||||
export async function startCatalogSyncJob(): Promise<CatalogSyncJob> {
|
||||
const id = crypto.randomUUID();
|
||||
const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" });
|
||||
void executeCatalogSyncJob(job).catch(() => undefined);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function executeCatalogSyncJob(initial: CatalogSyncJob) {
|
||||
const checkCancelled = async () => {
|
||||
const current = await getCatalogSyncJob(initial.id);
|
||||
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
|
||||
};
|
||||
const report = async (phase: string, completed: number, total: number, message: string) => {
|
||||
const current = await getCatalogSyncJob(initial.id);
|
||||
if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED");
|
||||
await writeSyncJob({ ...initial, status: "running", phase, completed, total, message });
|
||||
};
|
||||
try {
|
||||
await writeSyncJob({ ...initial, status: "running", phase: "version", message: "กำลังตรวจสอบเวอร์ชัน Lunaris" });
|
||||
const result = await processCatalogSync(undefined, { checkCancelled, report });
|
||||
await writeSyncJob({ ...initial, status: result === "unchanged" ? "unchanged" : "completed", phase: "done", completed: 1, total: 1, message: result === "unchanged" ? "Catalog เป็นเวอร์ชันล่าสุดแล้ว" : "Sync Catalog สำเร็จ" });
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") {
|
||||
await writeSyncJob({ ...initial, status: "cancelled", phase: "cancelled", message: "ยกเลิก Sync แล้ว" });
|
||||
} else {
|
||||
await writeSyncJob({ ...initial, status: "error", phase: "error", message: "Sync ไม่สำเร็จ", error: cause instanceof Error ? cause.message : "Unknown sync error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentCatalogVersion(): Promise<string | null> {
|
||||
const storage = await getMediaStorage();
|
||||
const current = storage.file("catalog/current.json");
|
||||
@@ -566,6 +643,7 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
|
||||
|
||||
export async function processCatalogSync(
|
||||
requestedVersion?: string,
|
||||
control?: SyncControl,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const version = lunarisVersionSchema.parse(
|
||||
requestedVersion ?? (await getLatestLunarisVersion()),
|
||||
@@ -579,7 +657,8 @@ export async function processCatalogSync(
|
||||
) {
|
||||
return "unchanged";
|
||||
}
|
||||
await syncCatalogVersion(version);
|
||||
await control?.checkCancelled();
|
||||
await syncCatalogVersion(version, control);
|
||||
return "synced";
|
||||
});
|
||||
}
|
||||
|
||||
+10
-2
@@ -9,6 +9,7 @@ import {
|
||||
dataSources,
|
||||
dataSourceVersions,
|
||||
guides,
|
||||
guidePosterBleeding,
|
||||
media,
|
||||
pageDataSources,
|
||||
pages,
|
||||
@@ -309,7 +310,7 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
retained: boolean;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const [referencedBlocks, portraitCharacters, posterGuides, [mediaRecord]] = await Promise.all([
|
||||
const [referencedBlocks, portraitCharacters, posterGuides, bleedingGuides, [mediaRecord]] = await Promise.all([
|
||||
db
|
||||
.select({ block: blocks, page: pages, character: characters })
|
||||
.from(blocks)
|
||||
@@ -323,6 +324,11 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
.select({ guide: guides })
|
||||
.from(guides)
|
||||
.where(eq(guides.posterArtMediaId, mediaId)),
|
||||
db
|
||||
.select({ guide: guides })
|
||||
.from(guidePosterBleeding)
|
||||
.innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId))
|
||||
.where(eq(guidePosterBleeding.mediaId, mediaId)),
|
||||
db.select().from(media).where(eq(media.id, mediaId)).limit(1),
|
||||
]);
|
||||
|
||||
@@ -342,11 +348,13 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
references.some(
|
||||
({ page, character }) => page.visible && character.visible,
|
||||
) || portraitCharacters.some(({ character }) => character.visible) ||
|
||||
posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt),
|
||||
posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt) ||
|
||||
bleedingGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt),
|
||||
retained:
|
||||
references.length > 0 ||
|
||||
portraitCharacters.length > 0 ||
|
||||
posterGuides.length > 0 ||
|
||||
bleedingGuides.length > 0 ||
|
||||
Boolean(
|
||||
mediaRecord &&
|
||||
(mediaRecord.currentReferenceCount > 0 ||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
extraSectionRows,
|
||||
extraSections,
|
||||
guides,
|
||||
guidePosterBleeding,
|
||||
guideSections,
|
||||
media,
|
||||
outboxEvents,
|
||||
@@ -137,6 +138,13 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
||||
.limit(1);
|
||||
if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว");
|
||||
}
|
||||
if (data.posterBleeding !== undefined) {
|
||||
const bleedingMediaIds = [...new Set(data.posterBleeding.map((layer) => layer.mediaId))];
|
||||
const bleedingMedia = bleedingMediaIds.length
|
||||
? await tx.select({ id: media.id }).from(media).where(and(inArray(media.id, bleedingMediaIds), eq(media.status, "ready")))
|
||||
: [];
|
||||
if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว");
|
||||
}
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.update(guides).set({
|
||||
overview: data.overview,
|
||||
@@ -148,6 +156,25 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
||||
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
||||
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
||||
}).where(eq(guides.id, guideId));
|
||||
if (data.posterBleeding !== undefined) {
|
||||
const previous = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
|
||||
for (const layer of previous) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
|
||||
}
|
||||
await tx.delete(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
|
||||
if (data.posterBleeding.length) {
|
||||
await tx.insert(guidePosterBleeding).values(data.posterBleeding.map((layer, sortOrder) => ({
|
||||
guideId,
|
||||
mediaId: layer.mediaId,
|
||||
amount: Math.round(layer.amount),
|
||||
scale: Math.round(layer.scale),
|
||||
sortOrder,
|
||||
})));
|
||||
for (const layer of data.posterBleeding) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, layer.mediaId));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.coverMediaId !== data.coverMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
|
||||
@@ -412,11 +439,15 @@ export async function permanentlyDeleteGuide(guideId: string) {
|
||||
return getDb().transaction(async (tx) => {
|
||||
const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||
const bleeding = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId));
|
||||
await tx.delete(guides).where(eq(guides.id, guideId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
|
||||
if (guide.posterArtMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
|
||||
}
|
||||
for (const layer of bleeding) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId));
|
||||
}
|
||||
await emitGuideEvents(tx, guide.id, guide.version + 1);
|
||||
return { version: guide.version + 1 };
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
guides,
|
||||
guidePosterBleeding,
|
||||
guideSections,
|
||||
glossaryAliases,
|
||||
media,
|
||||
@@ -124,13 +125,14 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
||||
const materialKeys = [...new Set(
|
||||
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
|
||||
)];
|
||||
const [materials, poster] = await Promise.all([
|
||||
const [materials, poster, bleeding] = await Promise.all([
|
||||
materialKeys.length
|
||||
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
|
||||
: [],
|
||||
row.guide.posterArtMediaId
|
||||
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||
: Promise.resolve([]),
|
||||
getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
|
||||
]);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder));
|
||||
@@ -138,6 +140,7 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||
character: catalogCharacterWithUrls(row.character, materialByKey),
|
||||
sections,
|
||||
};
|
||||
@@ -159,7 +162,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
const materialKeys = [...new Set(
|
||||
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
|
||||
)];
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster] = await Promise.all([
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([
|
||||
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
|
||||
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
@@ -173,6 +176,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
row.guide.posterArtMediaId
|
||||
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||
: Promise.resolve([]),
|
||||
getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
|
||||
]);
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
@@ -221,6 +225,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
|
||||
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||
elementIconUrls,
|
||||
sections,
|
||||
|
||||
@@ -41,6 +41,11 @@ export const updateGuideSchema = z.strictObject({
|
||||
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(),
|
||||
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||
posterBleeding: z.array(z.strictObject({
|
||||
mediaId: z.string().uuid(),
|
||||
amount: z.number().finite().min(0).max(100),
|
||||
scale: z.number().finite().min(25).max(300),
|
||||
})).max(30).optional(),
|
||||
});
|
||||
|
||||
export const weaponSectionSchema = z.strictObject({
|
||||
|
||||
Reference in New Issue
Block a user