From ba0a7147145cde827db176dfff6b9bcb7c464e32 Mon Sep 17 00:00:00 2001 From: mycyg Date: Sun, 17 May 2026 02:25:09 +0800 Subject: [PATCH 01/17] Add posts module (POST/MEDICAL/RESOURCE) with LLM moderation & classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new modules join the existing activities module on a unified "广场": - POST: general dynamics — any VERIFIED+ member - MEDICAL: hospital/doctor reviews — any VERIFIED+ posts, all comment - RESOURCE: skills on offer or requests for help — VERIFIED+ posts Activities are tightened to TRUSTED+ to match their higher trust profile. Every post, comment, and event now passes through containsBlockedTerms (cheap regex backstop) and then moderateAndClassify, which calls an Anthropic-compatible /v1/messages endpoint (e.g. Deepseek). The LLM returns a verdict (pass/flag/reject), a user-facing reason, the true section the content belongs in, and risk categories. Verdict drives status: pass->PUBLISHED, flag->PENDING_REVIEW (admin queue), reject->400 with reason. Section mismatch (e.g. an "event" posted as POST) is rejected with guidance. The service fails closed: any network/parsing error becomes a flag so content is parked for human review rather than silently dropped. D1 schema 0005 adds the posts table, the moderation result columns on both posts and events, and rebuilds comments to allow polymorphic event_id XOR post_id. Routes added: - GET/POST /api/posts, GET/PATCH/DELETE /api/posts/:id - GET/POST /api/posts/:id/comments, PATCH /api/posts/comments/:cid/hide - GET /api/posts/admin/pending, PATCH /api/posts/:id/review Frontend adds /posts list (tab-filtered by section), /posts/new, /posts/[id] (with pending-review banner for the author/admin), /admin/posts review queue, and a "广场" entry in TopBar/MobileNav. Vitest covers the LLM parser and fail-closed branches (13 tests). Integration verified locally end-to-end against Deepseek v4-flash: benign passes, keyword spam rejected at the door, hookup content rejected by LLM, event-shaped content from non-TRUSTED rejected with guidance, MEDICAL+RESOURCE posts published, comments moderated. Co-Authored-By: Claude Opus 4.7 (1M context) --- prisma/schema.prisma | 64 ++- src/app/(app)/posts/[id]/page.tsx | 159 ++++++ src/app/(app)/posts/new/page.tsx | 48 ++ src/app/(app)/posts/page.tsx | 133 +++++ src/app/admin/layout.tsx | 1 + src/app/admin/posts/page.tsx | 107 ++++ src/components/layout/MobileNav.tsx | 5 +- src/components/layout/TopBar.tsx | 6 + src/components/post/CommentSection.tsx | 111 ++++ src/components/post/PostCard.tsx | 65 +++ src/components/post/PostForm.tsx | 270 +++++++++ src/lib/access/index.ts | 17 + src/lib/api.ts | 120 +++- src/lib/enums.ts | 44 ++ src/lib/validators/post.ts | 42 ++ worker/.dev.vars.example | 26 + .../migrations/0005_posts_and_moderation.sql | 68 +++ worker/src/index.ts | 2 + worker/src/lib/access.ts | 3 +- worker/src/lib/keywords.ts | 14 + worker/src/lib/llm.ts | 247 ++++++++ worker/src/routes/events.ts | 122 +++- worker/src/routes/posts.ts | 530 ++++++++++++++++++ worker/src/types.ts | 53 +- worker/test/llm.test.ts | 177 ++++++ 25 files changed, 2416 insertions(+), 18 deletions(-) create mode 100644 src/app/(app)/posts/[id]/page.tsx create mode 100644 src/app/(app)/posts/new/page.tsx create mode 100644 src/app/(app)/posts/page.tsx create mode 100644 src/app/admin/posts/page.tsx create mode 100644 src/components/post/CommentSection.tsx create mode 100644 src/components/post/PostCard.tsx create mode 100644 src/components/post/PostForm.tsx create mode 100644 src/lib/validators/post.ts create mode 100644 worker/.dev.vars.example create mode 100644 worker/migrations/0005_posts_and_moderation.sql create mode 100644 worker/src/lib/keywords.ts create mode 100644 worker/src/lib/llm.ts create mode 100644 worker/src/routes/posts.ts create mode 100644 worker/test/llm.test.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1eaa9d4..129cdd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,16 @@ model Event { // EventStatus: DRAFT | PUBLISHED | CANCELLED | FINISHED status String @default("PUBLISHED") + // LLM moderation outcome (migration 0005) + moderationVerdict String? + moderationReason String? + moderationCategories String? + moderationRaw String? + moderationClassifier String? + moderatedAt DateTime? + reviewedById String? + reviewedAt DateTime? + registrations Registration[] comments Comment[] @@ -236,6 +246,51 @@ model Event { @@index([visibility, status]) } +// ============================================================ +// 帖子(POST 普通 / MEDICAL 医疗信息 / RESOURCE 资源分享) +// 与活动并列的发帖系统,由 LLM 自动审核 + 板块分类。 +// ============================================================ + +model Post { + id String @id @default(cuid()) + authorId String + // PostSection: POST | MEDICAL | RESOURCE + section String + title String + body String + // JSON array (free-form tags) + tags String @default("[]") + // MEDICAL-specific + hospital String? + doctor String? + city String? + // RESOURCE-specific: OFFER (technique on offer) | REQUEST (asking for help) + resourceKind String? + coverUrl String? + + // Visibility: PUBLIC | VERIFIED | TRUSTED + visibility String @default("VERIFIED") + // PostStatus: PENDING_REVIEW | PUBLISHED | REJECTED | HIDDEN + status String @default("PENDING_REVIEW") + + moderationVerdict String? + moderationReason String? + moderationCategories String? + moderationRaw String? + moderationClassifier String? + moderatedAt DateTime? + reviewedById String? + reviewedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([section, status]) + @@index([authorId]) + @@index([hospital]) + @@index([createdAt]) +} + model Registration { id String @id @default(cuid()) eventId String @@ -260,8 +315,12 @@ model Registration { model Comment { id String @id @default(cuid()) - eventId String - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + // Polymorphic: exactly one of eventId or postId is set. + // Schema kept nullable so Prisma can model both targets; + // application code enforces XOR. + eventId String? + event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade) + postId String? authorId String author User @relation(fields: [authorId], references: [id]) body String @@ -273,6 +332,7 @@ model Comment { createdAt DateTime @default(now()) @@index([eventId]) + @@index([postId]) @@index([authorId]) } diff --git a/src/app/(app)/posts/[id]/page.tsx b/src/app/(app)/posts/[id]/page.tsx new file mode 100644 index 0000000..7bf113f --- /dev/null +++ b/src/app/(app)/posts/[id]/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import * as React from "react"; +import { Suspense } from "react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { Trash2, ArrowLeft } from "lucide-react"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { CommentSection } from "@/components/post/CommentSection"; +import { useToast } from "@/components/ui/toast-context"; +import { + POST_SECTION_LABEL, + POST_STATUS_LABEL, + RESOURCE_KIND_LABEL, + type PostSection, + type PostStatus, + type ResourceKind, +} from "@/lib/enums"; +import { relativeTime } from "@/lib/utils"; +import { toQueryRoute } from "@/lib/query-routing"; + +function PostDetailInner() { + const params = useParams<{ id: string }>(); + const id = params?.id; + const { user } = useAuth(); + const router = useRouter(); + const { toast } = useToast(); + const [post, setPost] = React.useState(null); + const [notFound, setNotFound] = React.useState(false); + + React.useEffect(() => { + if (!id) return; + api.posts + .get(id) + .then((res) => setPost(res.post)) + .catch(() => setNotFound(true)); + }, [id]); + + if (notFound) { + return ( +
+

帖子不存在或无权访问。

+
+ ); + } + if (!post || !id) return null; + + const isAuthor = !!user && user.id === post.author_id; + const isAdmin = user?.tier === "ADMIN"; + const pending = post.status === "PENDING_REVIEW"; + const rejected = post.status === "REJECTED"; + const hidden = post.status === "HIDDEN"; + + async function remove() { + if (!id) return; + if (!confirm("确认删除这条帖子?")) return; + try { + await api.posts.remove(id); + toast({ title: "已删除", variant: "success" }); + router.push(toQueryRoute("/posts")); + } catch (err) { + const msg = err instanceof Error ? err.message : "删除失败"; + toast({ title: "删除失败", description: msg, variant: "danger" }); + } + } + + return ( +
+
+ + 返回广场 + +
+ + {pending && (isAuthor || isAdmin) ? ( + + +

+ 等待人工复核 + {" — "}AI 标记此内容需要管理员确认。当前仅你本人和管理员可见。 +

+ {post.moderation_reason ?

原因:{post.moderation_reason}

: null} +
+
+ ) : null} + + {rejected && (isAuthor || isAdmin) ? ( + + +

+ 已被拒绝{post.moderation_reason ? ` — ${post.moderation_reason}` : ""} +

+
+
+ ) : null} + + {hidden && (isAuthor || isAdmin) ? ( + + +

该帖已被管理员隐藏。

+
+
+ ) : null} + +
+
+ {POST_SECTION_LABEL[post.section as PostSection]} + {post.section === "RESOURCE" && post.resource_kind ? ( + + {RESOURCE_KIND_LABEL[post.resource_kind as ResourceKind]} + + ) : null} + {post.section === "MEDICAL" && post.hospital ? ( + {post.hospital} + ) : null} + {post.status !== "PUBLISHED" ? ( + {POST_STATUS_LABEL[post.status as PostStatus]} + ) : null} +
+

{post.title}

+
+ + {post.author_name} + {" "} + · {relativeTime(new Date(post.created_at))} +
+
{post.body}
+ + {(isAuthor || isAdmin) ? ( +
+ +
+ ) : null} +
+ + {post.status === "PUBLISHED" ? : null} +
+ ); +} + +export default function PostDetailPage() { + return ( + + + + ); +} diff --git a/src/app/(app)/posts/new/page.tsx b/src/app/(app)/posts/new/page.tsx new file mode 100644 index 0000000..96e803c --- /dev/null +++ b/src/app/(app)/posts/new/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Suspense } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; +import { canCreatePost } from "@/lib/access"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty"; +import { PostForm } from "@/components/post/PostForm"; +import { toQueryRoute } from "@/lib/query-routing"; + +function NewPostInner() { + const { user, loading } = useAuth(); + const router = useRouter(); + + if (loading) return null; + if (!user) { + router.replace(toQueryRoute("/sign-in?callbackUrl=/posts/new")); + return null; + } + if (!canCreatePost(user)) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} + +export default function NewPostPage() { + return ( + + + + ); +} diff --git a/src/app/(app)/posts/page.tsx b/src/app/(app)/posts/page.tsx new file mode 100644 index 0000000..e4b6c37 --- /dev/null +++ b/src/app/(app)/posts/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { Plus } from "lucide-react"; +import { api, type Post } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { canCreatePost } from "@/lib/access"; +import { PageHeader } from "@/components/ui/page-header"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty"; +import { PostCard } from "@/components/post/PostCard"; +import { POST_SECTION_LABEL, type PostSection } from "@/lib/enums"; +import { getQueryRoute, toQueryRoute } from "@/lib/query-routing"; + +const SECTIONS: PostSection[] = ["POST", "MEDICAL", "RESOURCE"]; + +function PostsListInner() { + const { user } = useAuth(); + const searchParams = useSearchParams(); + const queryRoute = React.useMemo(() => getQueryRoute(searchParams), [searchParams]); + const isLiteralPostsRoute = queryRoute.path === "/posts" || queryRoute.path.startsWith("/posts/"); + const routeParams = isLiteralPostsRoute ? queryRoute.params : searchParams; + const section = (routeParams.get("section") as PostSection | null) ?? null; + const hospital = routeParams.get("hospital") ?? undefined; + const q = routeParams.get("q") ?? undefined; + + const [posts, setPosts] = React.useState([]); + const [loadFailed, setLoadFailed] = React.useState(false); + + React.useEffect(() => { + api.posts + .list({ section: section ?? undefined, hospital, q }) + .then((res) => { + setPosts(res.posts); + setLoadFailed(false); + }) + .catch(() => { + setPosts([]); + setLoadFailed(true); + }); + }, [section, hospital, q]); + + return ( +
+ + + 发帖 + + + ) : null + } + /> + +
+ + 全部 + + {SECTIONS.map((s) => ( + + {POST_SECTION_LABEL[s]} + + ))} +
+ + {posts.length === 0 ? ( + + 发个帖 + + ) : null + } + /> + ) : ( +
+ {posts.map((p) => ( + + ))} +
+ )} +
+ ); +} + +export default function PostsPage() { + return ( + + + + ); +} + +function FilterPill({ + href, + active, + children, +}: { + href: string; + active: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 255d9e2..23b651b 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -27,6 +27,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) 管理后台 申请审核 + 帖子复核 举报队列 用户管理 操作日志 diff --git a/src/app/admin/posts/page.tsx b/src/app/admin/posts/page.tsx new file mode 100644 index 0000000..bbfd72f --- /dev/null +++ b/src/app/admin/posts/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { api, type Post } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "@/components/ui/empty"; +import { useToast } from "@/components/ui/toast-context"; +import { PageHeader } from "@/components/ui/page-header"; +import { POST_SECTION_LABEL, type PostSection } from "@/lib/enums"; +import { toQueryRoute } from "@/lib/query-routing"; +import { relativeTime } from "@/lib/utils"; + +export default function AdminPostsPage() { + const { toast } = useToast(); + const [posts, setPosts] = React.useState([]); + const [loading, setLoading] = React.useState(true); + + const refresh = React.useCallback(() => { + setLoading(true); + api.admin + .listPendingPosts() + .then((res) => setPosts(res.posts)) + .catch(() => setPosts([])) + .finally(() => setLoading(false)); + }, []); + + React.useEffect(() => { + refresh(); + }, [refresh]); + + async function decide(id: string, decision: "APPROVE" | "REJECT" | "HIDE") { + try { + await api.admin.reviewPost(id, decision); + toast({ + title: + decision === "APPROVE" ? "已放行" : decision === "REJECT" ? "已拒绝" : "已隐藏", + variant: "success", + }); + refresh(); + } catch (err) { + const msg = err instanceof Error ? err.message : "操作失败"; + toast({ title: "操作失败", description: msg, variant: "danger" }); + } + } + + return ( +
+ + + {loading ? ( +

加载中…

+ ) : posts.length === 0 ? ( + + ) : ( +
+ {posts.map((p) => ( + + +
+ {POST_SECTION_LABEL[p.section as PostSection]} + PENDING_REVIEW + + {p.author_name} · {relativeTime(new Date(p.created_at))} + +
+ + {p.title} + +

{p.body}

+ {p.moderation_reason ? ( +
+ AI 判断:{p.moderation_verdict} ·{" "} + {p.moderation_reason} + {p.moderation_categories && p.moderation_categories !== "[]" ? ( + <> · 标签:{p.moderation_categories} + ) : null} +
+ ) : null} +
+ + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index e42459d..4f79c62 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Calendar, Home, User, Bell } from "lucide-react"; +import { Calendar, Home, User, Bell, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { toQueryRoute } from "@/lib/query-routing"; @@ -11,6 +11,7 @@ export function MobileNav({ signedIn }: { signedIn: boolean }) { const items = [ { href: toQueryRoute("/events"), label: "活动", icon: Calendar, match: /^\/events/ }, + { href: toQueryRoute("/posts"), label: "广场", icon: Sparkles, match: /^\/posts/ }, { href: toQueryRoute("/me"), label: "我", icon: Home, match: /^\/me$/ }, { href: toQueryRoute("/notifications"), label: "通知", icon: Bell, match: /^\/notifications/ }, { href: toQueryRoute("/me/profile"), label: "主页", icon: User, match: /^\/me\/profile/ }, @@ -18,7 +19,7 @@ export function MobileNav({ signedIn }: { signedIn: boolean }) { return (