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.
308 lines
8.9 KiB
308 lines
8.9 KiB
import { dbGlobal } from "drizzle-pkg/lib/db";
|
|
import { postMediaRefs, posts } 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 } from "drizzle-orm";
|
|
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";
|
|
|
|
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 字符)" });
|
|
}
|
|
}
|
|
|
|
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 getPostForUser(userId: number, id: number) {
|
|
const [row] = await dbGlobal
|
|
.select()
|
|
.from(posts)
|
|
.where(and(eq(posts.id, id), eq(posts.userId, userId)))
|
|
.limit(1);
|
|
return row ?? null;
|
|
}
|
|
|
|
export async function createPost(
|
|
userId: number,
|
|
input: {
|
|
title: string;
|
|
slug: string;
|
|
bodyMarkdown: string;
|
|
excerpt: string;
|
|
coverUrl?: string | null;
|
|
tagsJson?: 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,
|
|
});
|
|
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;
|
|
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)));
|
|
|
|
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 refRows = await dbGlobal
|
|
.select({ assetId: postMediaRefs.assetId })
|
|
.from(postMediaRefs)
|
|
.where(eq(postMediaRefs.postId, id));
|
|
const touched = refRows.map((r) => r.assetId);
|
|
await dbGlobal.delete(posts).where(and(eq(posts.id, id), eq(posts.userId, userId)));
|
|
await reconcileAssetTimestampsAfterRefChange(touched);
|
|
return true;
|
|
}
|
|
|
|
const publicPostsListSelect = {
|
|
title: posts.title,
|
|
excerpt: posts.excerpt,
|
|
slug: posts.slug,
|
|
coverUrl: posts.coverUrl,
|
|
publishedAt: posts.publishedAt,
|
|
} 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;
|
|
}>;
|
|
total: number;
|
|
}> {
|
|
const whereClause = publicPostsListWhere(publicSlug);
|
|
const [countRows, items] = 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),
|
|
]);
|
|
return { items, total: countRows[0]?.total ?? 0 };
|
|
}
|
|
|
|
export async function getPublicPostsPageBySlug(
|
|
publicSlug: string,
|
|
pageRaw: unknown,
|
|
): Promise<{
|
|
items: Array<{
|
|
title: string;
|
|
excerpt: string;
|
|
slug: string;
|
|
coverUrl: string | null;
|
|
publishedAt: Date | null;
|
|
}>;
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}> {
|
|
const page = normalizePublicListPage(pageRaw);
|
|
const pageSize = PUBLIC_LIST_PAGE_SIZE;
|
|
const offset = (page - 1) * pageSize;
|
|
const whereClause = publicPostsListWhere(publicSlug);
|
|
const [countRows, items] = 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(pageSize)
|
|
.offset(offset),
|
|
]);
|
|
return { items, total: countRows[0]?.total ?? 0, page, pageSize };
|
|
}
|
|
|
|
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);
|
|
return row ?? null;
|
|
}
|
|
|
|
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);
|
|
return row?.post ?? null;
|
|
}
|
|
|