From f1ed1e7e4098a8dd7049792a34c771659334f807 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 00:22:48 +0800 Subject: [PATCH] feat(life): implement soft delete for life posts Add deleted_at and deleted_by_user_id to life_posts schema Update queries to filter out and prevent interactions with deleted posts --- DESIGN.md | 4 +++- backend/db/schema.sql | 8 ++++++++ backend/src/queries.ts | 33 +++++++++++++++++++++++++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d3d2c75..f5b0473 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -378,10 +378,11 @@ Life Post 可配置: - 所有人都可以浏览 Life 信息流。 - 信息流按创建时间倒序展示。 - 已注册并完成邮箱验证的用户可以发布 Life Post。 -- 作者本人可以编辑、删除自己的 Life Post。 +- 作者本人可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。 - 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。 - 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。 - 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 +- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 @@ -399,6 +400,7 @@ API 暴露边界: - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 - Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 +- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。 - 非作者不能编辑或删除其他用户的 Life Post。 - 非作者不能删除其他用户的 Life Comment。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index fa9cdb0..9fddcf1 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -136,16 +136,24 @@ CREATE TABLE IF NOT EXISTS life_posts ( body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url; ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title; +ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_at timestamptz; CREATE INDEX IF NOT EXISTS life_posts_created_at_idx ON life_posts(created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx + ON life_posts(created_at DESC, id DESC) + WHERE deleted_at IS NULL; + CREATE TABLE IF NOT EXISTS life_post_tags ( post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 39854e1..0e0d7f8 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1509,7 +1509,7 @@ export async function listLifePosts( const search = asString(paramsQuery.search)?.trim(); const tagIdValue = asString(paramsQuery.tagId)?.trim(); const params: unknown[] = []; - const conditions: string[] = []; + const conditions: string[] = ['lp.deleted_at IS NULL']; if (search) { params.push(`%${search}%`); @@ -1562,6 +1562,7 @@ async function getLifePostById(id: number, userId: number | null = null, locale ` ${lifePostProjection(locale)} WHERE lp.id = $1 + AND lp.deleted_at IS NULL `, [id] ); @@ -1620,6 +1621,7 @@ export async function updateLifePost(id: number, payload: Record( ` - DELETE FROM life_posts + UPDATE life_posts + SET deleted_at = now(), + deleted_by_user_id = $2, + updated_by_user_id = $2, + updated_at = now() WHERE id = $1 AND created_by_user_id = $2 + AND deleted_at IS NULL RETURNING id `, [id, userId] @@ -1663,7 +1670,12 @@ export async function setLifePostReaction( ` INSERT INTO life_post_reactions (post_id, user_id, reaction_type) SELECT $1, $2, $3 - WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1) + WHERE EXISTS ( + SELECT 1 + FROM life_posts + WHERE id = $1 + AND deleted_at IS NULL + ) ON CONFLICT (post_id, user_id) DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now() RETURNING post_id AS "postId" @@ -1680,6 +1692,12 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc DELETE FROM life_post_reactions WHERE post_id = $1 AND user_id = $2 + AND EXISTS ( + SELECT 1 + FROM life_posts + WHERE id = $1 + AND deleted_at IS NULL + ) RETURNING post_id AS "postId" `, [postId, userId] @@ -1695,7 +1713,12 @@ export async function createLifeComment(postId: number, payload: Record