feat: implement post existence check and detail page
This commit is contained in:
parent
55af0549e7
commit
b18cff8b1a
10 changed files with 646 additions and 43 deletions
|
|
@ -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 };
|
||||
49
apps/backend/src/lib/post.ts
Normal file
49
apps/backend/src/lib/post.ts
Normal 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 };
|
||||
|
|
@ -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: "/",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue