You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

521 lines
16 KiB

import { dbGlobal } from "drizzle-pkg/lib/db";
import { mediaRefs, postTags, posts, tags } from "drizzle-pkg/lib/schema/content";
import { reconcileAssetTimestampsAfterRefChange, syncPostMediaRefs } from "#server/service/media";
import { users } from "drizzle-pkg/lib/schema/auth";
import { and, count, desc, eq, inArray, or } from "drizzle-orm";
import { MEDIA_REF_OWNER_POST } from "#server/constants/media-refs";
import { PUBLIC_LIST_PAGE_SIZE, PUBLIC_PREVIEW_LIMIT } from "#server/constants/public-profile-lists";
import { visibilitySchema, type Visibility } from "#server/constants/visibility";
import { normalizePublicListPage } from "#server/utils/public-pagination";
import { visibilityShareToken } from "#server/utils/share-token";
import { nextIntegerId } from "#server/utils/sqlite-id";
import { listCardSummaryFromPostBody } from "../../../app/utils/markdown-front-matter";
import { type TagMode } from "../../utils/post-tags";
import {
getPostTagNames,
getPostTagNamesForPostIds,
listTagSlugsForUser,
replacePostTagRelations,
upsertTagsForUser,
} from "../post-tags";
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$|^[a-z0-9]$/;
function assertSlug(slug: string) {
if (!SLUG_RE.test(slug)) {
throw createError({ statusCode: 400, statusMessage: "slug 格式无效(小写字母、数字、短横线,1–100 字符)" });
}
}
async function resolveMatchedPostIdsForUser(userId: number, selectedTags: string[], tagMode: TagMode) {
if (!selectedTags.length) {
return null;
}
const rows = await dbGlobal
.select({ postId: postTags.postId, slug: tags.slug })
.from(postTags)
.innerJoin(tags, eq(postTags.tagId, tags.id))
.innerJoin(posts, eq(postTags.postId, posts.id))
.where(and(eq(posts.userId, userId), inArray(tags.slug, selectedTags)));
const bucket = new Map<number, Set<string>>();
for (const row of rows) {
const set = bucket.get(row.postId) ?? new Set<string>();
set.add(row.slug);
bucket.set(row.postId, set);
}
if (tagMode === "or") {
return [...bucket.keys()];
}
return [...bucket.entries()].filter(([, set]) => set.size === selectedTags.length).map(([id]) => id);
}
async function resolveMatchedPublicPostIds(
publicSlug: string,
selectedTags: string[],
tagMode: TagMode,
) {
if (!selectedTags.length) {
return null;
}
const rows = await dbGlobal
.select({ postId: postTags.postId, slug: tags.slug })
.from(postTags)
.innerJoin(tags, eq(postTags.tagId, tags.id))
.innerJoin(posts, eq(postTags.postId, posts.id))
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "public"),
inArray(tags.slug, selectedTags),
),
);
const bucket = new Map<number, Set<string>>();
for (const row of rows) {
const set = bucket.get(row.postId) ?? new Set<string>();
set.add(row.slug);
bucket.set(row.postId, set);
}
if (tagMode === "or") {
return [...bucket.keys()];
}
return [...bucket.entries()].filter(([, set]) => set.size === selectedTags.length).map(([id]) => id);
}
export async function listPostsForUser(userId: number) {
return dbGlobal
.select()
.from(posts)
.where(eq(posts.userId, userId))
.orderBy(desc(posts.publishedAt), desc(posts.id));
}
export async function listPostsPageForUser(
userId: number,
pageRaw: unknown,
pageSize = 20,
filter?: { selectedTags: string[]; tagMode: TagMode },
) {
const page = normalizePublicListPage(pageRaw);
const offset = (page - 1) * pageSize;
const matchedIds = await resolveMatchedPostIdsForUser(userId, filter?.selectedTags ?? [], filter?.tagMode ?? "or");
if (matchedIds && matchedIds.length === 0) {
return { items: [], total: 0, page, pageSize, availableTags: await listTagSlugsForUser(userId) };
}
const whereClause =
matchedIds == null
? eq(posts.userId, userId)
: and(eq(posts.userId, userId), inArray(posts.id, matchedIds));
const [countRows, items, availableTags] = await Promise.all([
dbGlobal.select({ total: count() }).from(posts).where(whereClause),
dbGlobal
.select()
.from(posts)
.where(whereClause)
.orderBy(desc(posts.publishedAt), desc(posts.id))
.limit(pageSize)
.offset(offset),
listTagSlugsForUser(userId),
]);
const tagByPost = await getPostTagNamesForPostIds(items.map((p) => p.id));
const itemsWithTags = items.map((p) => ({ ...p, tags: tagByPost.get(p.id) ?? [] }));
return {
items: itemsWithTags,
total: countRows[0]?.total ?? 0,
page,
pageSize,
availableTags,
selectedTags: filter?.selectedTags ?? [],
tagMode: filter?.tagMode ?? "or",
};
}
export async function getPostForUser(userId: number, id: number) {
const [row] = await dbGlobal
.select()
.from(posts)
.where(and(eq(posts.id, id), eq(posts.userId, userId)))
.limit(1);
if (!row) {
return null;
}
const tagNames = await getPostTagNames(row.id);
return { ...row, tags: tagNames };
}
export async function createPost(
userId: number,
input: {
title: string;
slug: string;
bodyMarkdown: string;
excerpt: string;
coverUrl?: string | null;
tagsJson?: string;
tags?: string[];
publishedAt?: Date | null;
visibility: Visibility;
},
) {
assertSlug(input.slug);
const vis = visibilitySchema.parse(input.visibility);
const shareToken = visibilityShareToken(vis, null);
const id = await nextIntegerId(posts, posts.id);
await dbGlobal.insert(posts).values({
id,
userId,
title: input.title.trim(),
slug: input.slug.trim(),
bodyMarkdown: input.bodyMarkdown,
excerpt: input.excerpt,
coverUrl: input.coverUrl?.trim() || undefined,
tagsJson: input.tagsJson ?? "[]",
publishedAt: input.publishedAt ?? new Date(),
visibility: vis,
shareToken,
});
const tagIds = await upsertTagsForUser(userId, input.tags ?? []);
await replacePostTagRelations(id, tagIds);
await syncPostMediaRefs(userId, id, input.bodyMarkdown, input.coverUrl ?? null);
return getPostForUser(userId, id);
}
export async function updatePost(
userId: number,
id: number,
patch: Partial<{
title: string;
slug: string;
bodyMarkdown: string;
excerpt: string;
coverUrl: string | null;
tagsJson: string;
tags: string[];
publishedAt: Date | null;
visibility: Visibility;
}>,
) {
const existing = await getPostForUser(userId, id);
if (!existing) {
return null;
}
if (patch.slug !== undefined) {
assertSlug(patch.slug);
}
const nextVis = patch.visibility !== undefined ? visibilitySchema.parse(patch.visibility) : (existing.visibility as Visibility);
const shareToken =
patch.visibility !== undefined
? visibilityShareToken(nextVis, existing.shareToken)
: existing.shareToken;
await dbGlobal
.update(posts)
.set({
...(patch.title !== undefined ? { title: patch.title.trim() } : {}),
...(patch.slug !== undefined ? { slug: patch.slug.trim() } : {}),
...(patch.bodyMarkdown !== undefined ? { bodyMarkdown: patch.bodyMarkdown } : {}),
...(patch.excerpt !== undefined ? { excerpt: patch.excerpt } : {}),
...(patch.coverUrl !== undefined ? { coverUrl: patch.coverUrl?.trim() || null } : {}),
...(patch.tagsJson !== undefined ? { tagsJson: patch.tagsJson } : {}),
...(patch.publishedAt !== undefined ? { publishedAt: patch.publishedAt } : {}),
...(patch.visibility !== undefined ? { visibility: nextVis, shareToken } : {}),
})
.where(and(eq(posts.id, id), eq(posts.userId, userId)));
if (patch.tags !== undefined) {
const tagIds = await upsertTagsForUser(userId, patch.tags);
await replacePostTagRelations(id, tagIds);
}
const body = patch.bodyMarkdown !== undefined ? patch.bodyMarkdown : existing.bodyMarkdown;
const cover = patch.coverUrl !== undefined ? patch.coverUrl : existing.coverUrl;
await syncPostMediaRefs(userId, id, body, cover ?? null);
return getPostForUser(userId, id);
}
export async function deletePost(userId: number, id: number) {
const existing = await getPostForUser(userId, id);
if (!existing) {
return false;
}
const postRef = and(eq(mediaRefs.ownerType, MEDIA_REF_OWNER_POST), eq(mediaRefs.ownerId, id));
const refRows = await dbGlobal.select({ assetId: mediaRefs.assetId }).from(mediaRefs).where(postRef);
const touched = refRows.map((r) => r.assetId);
await dbGlobal.delete(mediaRefs).where(postRef);
await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId)));
await reconcileAssetTimestampsAfterRefChange(touched);
return true;
}
const publicPostsListSelect = {
id: posts.id,
title: posts.title,
excerpt: posts.excerpt,
slug: posts.slug,
coverUrl: posts.coverUrl,
publishedAt: posts.publishedAt,
bodyMarkdown: posts.bodyMarkdown,
} as const;
function publicPostsListWhere(publicSlug: string) {
return and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "public"),
);
}
export async function getPublicPostsPreviewBySlug(publicSlug: string): Promise<{
items: Array<{
title: string;
excerpt: string;
slug: string;
coverUrl: string | null;
publishedAt: Date | null;
tags: string[];
}>;
total: number;
}> {
const whereClause = publicPostsListWhere(publicSlug);
const [countRows, rawItems] = await Promise.all([
dbGlobal
.select({ total: count() })
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(whereClause),
dbGlobal
.select(publicPostsListSelect)
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(whereClause)
.orderBy(desc(posts.publishedAt), desc(posts.id))
.limit(PUBLIC_PREVIEW_LIMIT),
]);
const tagByPost = await getPostTagNamesForPostIds(rawItems.map((r) => r.id));
const items = rawItems.map(({ bodyMarkdown, excerpt, id, ...rest }) => ({
...rest,
excerpt: listCardSummaryFromPostBody(bodyMarkdown, excerpt),
tags: tagByPost.get(id) ?? [],
}));
return { items, total: countRows[0]?.total ?? 0 };
}
export async function getPublicPostsPageBySlug(
publicSlug: string,
pageRaw: unknown,
filter?: { selectedTags: string[]; tagMode: TagMode; ownerUserId?: number },
): Promise<{
items: Array<{
title: string;
excerpt: string;
slug: string;
coverUrl: string | null;
publishedAt: Date | null;
tags: string[];
}>;
total: number;
page: number;
pageSize: number;
availableTags: string[];
selectedTags: string[];
tagMode: TagMode;
}> {
const page = normalizePublicListPage(pageRaw);
const pageSize = PUBLIC_LIST_PAGE_SIZE;
const offset = (page - 1) * pageSize;
const matchedIds = await resolveMatchedPublicPostIds(publicSlug, filter?.selectedTags ?? [], filter?.tagMode ?? "or");
if (matchedIds && matchedIds.length === 0) {
return {
items: [],
total: 0,
page,
pageSize,
availableTags: filter?.ownerUserId ? await listTagSlugsForUser(filter.ownerUserId) : [],
selectedTags: filter?.selectedTags ?? [],
tagMode: filter?.tagMode ?? "or",
};
}
const whereClause = publicPostsListWhere(publicSlug);
const finalWhere =
matchedIds == null
? whereClause
: and(whereClause, inArray(posts.id, matchedIds));
const [countRows, rawItems] = await Promise.all([
dbGlobal
.select({ total: count() })
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(finalWhere),
dbGlobal
.select(publicPostsListSelect)
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(finalWhere)
.orderBy(desc(posts.publishedAt), desc(posts.id))
.limit(pageSize)
.offset(offset),
]);
const tagByPost = await getPostTagNamesForPostIds(rawItems.map((r) => r.id));
const items = rawItems.map(({ bodyMarkdown, excerpt, id, ...rest }) => ({
...rest,
excerpt: listCardSummaryFromPostBody(bodyMarkdown, excerpt),
tags: tagByPost.get(id) ?? [],
}));
return {
items,
total: countRows[0]?.total ?? 0,
page,
pageSize,
availableTags: filter?.ownerUserId ? await listTagSlugsForUser(filter.ownerUserId) : [],
selectedTags: filter?.selectedTags ?? [],
tagMode: filter?.tagMode ?? "or",
};
}
export async function getPublicPostByPublicSlugAndSlug(publicSlug: string, postSlug: string) {
const slug = postSlug.trim();
if (!slug) {
return null;
}
const [row] = await dbGlobal
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
excerpt: posts.excerpt,
bodyMarkdown: posts.bodyMarkdown,
coverUrl: posts.coverUrl,
publishedAt: posts.publishedAt,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "public"),
eq(posts.slug, slug),
),
)
.limit(1);
if (!row) {
return null;
}
const tagNames = await getPostTagNames(row.id);
return { ...row, tags: tagNames };
}
export async function getPostByPublicSlugAndSlugForViewer(
publicSlug: string,
postSlug: string,
viewerUserId: number | null,
) {
const slug = postSlug.trim();
if (!slug) {
return null;
}
const visibilityFilter =
viewerUserId != null
? or(eq(posts.visibility, "public"), eq(posts.userId, viewerUserId))
: eq(posts.visibility, "public");
const [row] = await dbGlobal
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
excerpt: posts.excerpt,
bodyMarkdown: posts.bodyMarkdown,
coverUrl: posts.coverUrl,
publishedAt: posts.publishedAt,
visibility: posts.visibility,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.slug, slug),
visibilityFilter,
),
)
.limit(1);
if (!row) {
return null;
}
const tagNames = await getPostTagNames(row.id);
return { ...row, tags: tagNames };
}
export async function getPublicPostCommentContext(
publicSlug: string,
postSlug: string,
): Promise<{ id: number; userId: number } | null> {
const slug = postSlug.trim();
if (!slug) {
return null;
}
const [row] = await dbGlobal
.select({
id: posts.id,
userId: posts.userId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "public"),
eq(posts.slug, slug),
),
)
.limit(1);
return row ?? null;
}
export async function getUnlistedPostCommentContext(
publicSlug: string,
shareToken: string,
): Promise<{ id: number; userId: number } | null> {
const [row] = await dbGlobal
.select({
id: posts.id,
userId: posts.userId,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "unlisted"),
eq(posts.shareToken, shareToken),
),
)
.limit(1);
return row ?? null;
}
export async function getUnlistedPost(publicSlug: string, shareToken: string) {
const [row] = await dbGlobal
.select({ post: posts })
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(
and(
eq(users.publicSlug, publicSlug),
eq(users.status, "active"),
eq(posts.visibility, "unlisted"),
eq(posts.shareToken, shareToken),
),
)
.limit(1);
const post = row?.post ?? null;
if (!post) {
return null;
}
const tagNames = await getPostTagNames(post.id);
return { ...post, tags: tagNames };
}