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

@ -1,3 +1,5 @@
import { MediaUpload } from "@/models/media";
function fetchPixivData(url: string): Promise<any> {
// https://www.pixiv.net/artworks/143552616
const match = url.match(/\/artworks\/(\d+)/);
@ -30,4 +32,27 @@ function fetchPixivData(url: string): Promise<any> {
});
}
export { fetchPixivData };
async function checkPixivData(url: string, selected: Array<boolean>) {
const match = url.match(/\/artworks\/(\d+)/);
if (!match) {
throw new Error("Invalid Pixiv URL");
}
const artworkId = match[1];
const selectedIndices = selected
.map((isSelected, index) => (isSelected ? index : -1))
.filter((index) => index >= 0);
if (selectedIndices.length === 0) {
return false;
}
const existing = await MediaUpload.findOne({
"tweet.id": artworkId,
mediaIndex: { $in: selectedIndices },
});
return existing !== null;
}
export { checkPixivData, fetchPixivData };

View file

@ -0,0 +1,49 @@
import { MediaUpload } from "@/models/media";
type PostSource = "twitter" | "pixiv";
function parsePostUrl(url: string): { source: PostSource; postId: string } | null {
const tweetMatch = url.match(/\/status\/(\d+)/);
if (tweetMatch) {
return {
source: "twitter",
postId: tweetMatch[1],
};
}
const pixivMatch = url.match(/\/artworks\/(\d+)/);
if (pixivMatch) {
return {
source: "pixiv",
postId: pixivMatch[1],
};
}
return null;
}
async function checkExistingPostByUrl(url: string) {
const parsed = parsePostUrl(url);
if (!parsed) {
return {
exists: false,
source: null as PostSource | null,
postId: null as string | null,
documentId: null as string | null,
};
}
const existing = await MediaUpload.findOne({
type: parsed.source,
"tweet.id": parsed.postId,
});
return {
exists: existing !== null,
source: parsed.source,
postId: parsed.postId,
documentId: existing?._id?.toString() ?? null,
};
}
export { checkExistingPostByUrl, parsePostUrl };

View file

@ -53,7 +53,7 @@ export default new Elysia({ prefix: "/auth" })
value: "",
httpOnly: true,
maxAge: 0,
path: "/api",
path: "/",
});
return { ok: true };
@ -141,7 +141,7 @@ export default new Elysia({ prefix: "/auth" })
value: token,
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
path: "/api",
path: "/",
});
return redirect("/");
@ -203,7 +203,7 @@ export default new Elysia({ prefix: "/auth" })
value: nextToken,
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
path: "/api",
path: "/",
});
}

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({