feat: implement post existence check and detail page

This commit is contained in:
암냥 2026-04-18 06:48:21 +09:00
commit b18cff8b1a
No known key found for this signature in database
10 changed files with 646 additions and 43 deletions

View file

@ -6,12 +6,56 @@ import { Tag } from "@/models/tag";
import { User } from "@/models/user";
import { createAuditLog } from "@/lib/audit";
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
import { checkTweetData, fetchTweetData } from "@/lib/tweet";
import { fetchTweetData } from "@/lib/tweet";
import { fetchPixivData } from "@/lib/pixiv";
import { checkExistingPostByUrl } from "@/lib/post";
import { makeS3FileName, s3Client, uploadToS3 } from "@/lib/s3";
const inFlightUploads = new Set<string>();
type UploadResponse = {
success: boolean;
message: string;
savedCount?: number;
failedCount?: number;
};
type ExistsResponse = {
success: boolean;
message: string;
exists: boolean;
source: "twitter" | "pixiv" | null;
postId: string | null;
documentId: string | null;
};
function uploadOk(message: string, extra?: Omit<UploadResponse, "success" | "message">): UploadResponse {
return {
success: true,
message,
...extra,
};
}
function uploadError(message: string, extra?: Omit<UploadResponse, "success" | "message">): UploadResponse {
return {
success: false,
message,
...extra,
};
}
function existsResponse(message: string, exists: boolean, source: ExistsResponse["source"], postId: string | null, documentId: string | null): ExistsResponse {
return {
success: true,
message,
exists,
source,
postId,
documentId,
};
}
function buildUploadKey(url: string, selected: boolean[]) {
const match = url.match(/\/status\/(\d+)/);
const tweetId = match?.[1] ?? url;
@ -169,6 +213,22 @@ export default new Elysia({ prefix: "/post" })
return tags;
})
.get("/exists", async ({ query }) => {
const result = await checkExistingPostByUrl(query.url);
return existsResponse(
result.exists ? "이미 저장된 게시물입니다." : "저장된 게시물이 없습니다.",
result.exists,
result.source,
result.postId,
result.documentId,
);
}, {
query: t.Object({
url: t.String(),
}),
})
.get("/detail/:id", async ({ params, status }) => {
const post = await MediaUpload.findById(params.id);
if (!post) {
@ -323,13 +383,18 @@ export default new Elysia({ prefix: "/post" })
return status(403, "Forbidden");
}
const existingPost = await checkExistingPostByUrl(body.url);
if (existingPost.exists) {
return uploadOk("이미 저장된 게시물입니다.", { savedCount: 0, failedCount: 0 });
}
if (body.url.startsWith("https://www.pixiv.net/")) {
const requestId = crypto.randomUUID();
const uploadKey = buildUploadKey(body.url, body.selected);
if (inFlightUploads.has(uploadKey)) {
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
return status(202, "이미 처리 중인 업로드입니다.");
return status(202, uploadError("이미 처리 중인 업로드입니다."));
}
inFlightUploads.add(uploadKey);
@ -342,7 +407,7 @@ export default new Elysia({ prefix: "/post" })
: [];
if (mediaUrls.length === 0) {
return status(400, "No media found in the Pixiv artwork.");
return status(400, uploadError("No media found in the Pixiv artwork."));
}
const normalizedTags = normalizeTags(body.tag ?? ["미분류"]);
@ -430,20 +495,26 @@ export default new Elysia({ prefix: "/post" })
});
console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`);
if (savedCount === 0 && failedCount > 0) {
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", {
savedCount,
failedCount,
}));
}
if (savedCount === 0) {
return status(400, "선택된 이미지가 없습니다.");
return status(400, uploadError("선택된 이미지가 없습니다.", {
savedCount,
failedCount,
}));
}
return {
return uploadOk("업로드가 완료되었습니다.", {
savedCount,
failedCount,
};
});
} catch (error) {
console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error);
return status(500, "Failed to fetch Pixiv data");
return status(500, uploadError("Failed to fetch Pixiv data"));
} finally {
inFlightUploads.delete(uploadKey);
}
@ -453,17 +524,12 @@ export default new Elysia({ prefix: "/post" })
if (inFlightUploads.has(uploadKey)) {
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
return status(202, "이미 처리 중인 업로드입니다.");
return status(202, uploadError("이미 처리 중인 업로드입니다."));
}
inFlightUploads.add(uploadKey);
console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`);
if (await checkTweetData(body.url, body.selected)) {
inFlightUploads.delete(uploadKey);
console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`);
return "이미 저장된 트윗입니다.";
}
try {
const tweetData = await fetchTweetData(body.url);
let savedCount = 0;
@ -544,31 +610,37 @@ export default new Elysia({ prefix: "/post" })
},
});
} else {
return status(400, "트윗에서 이미지를 찾지 못했습니다.");
return status(400, uploadError("트윗에서 이미지를 찾지 못했습니다."));
}
}
console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`);
if (savedCount === 0 && failedCount > 0) {
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
return status(502, uploadError("업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.", {
savedCount,
failedCount,
}));
}
if (savedCount === 0) {
return status(400, "선택된 이미지가 없습니다.");
return status(400, uploadError("선택된 이미지가 없습니다.", {
savedCount,
failedCount,
}));
}
return {
return uploadOk("업로드가 완료되었습니다.", {
savedCount,
failedCount,
};
});
} catch (error) {
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
console.error(error);
return status(500, "Failed to fetch tweet data");
return status(500, uploadError("Failed to fetch tweet data"));
} finally {
inFlightUploads.delete(uploadKey);
}
} else {
return status(400, "어...");
return status(400, uploadError("어..."));
}
}, {
body: t.Object({