From 3d6188748d8770c568281e83110e66ec74bb68ad Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 10:54:21 +0800 Subject: [PATCH] feat(moderation): add real-time status updates via WebSocket Broadcast moderation status changes to the author via WebSocket Update UI in real-time for Life Posts, Comments, and Discussions Hide retry moderation button while status is reviewing --- DESIGN.md | 5 +- backend/src/notifications.ts | 130 +++++++++++++++++- .../src/components/EntityDiscussionPanel.vue | 61 +++++++- frontend/src/components/NotificationBell.vue | 7 +- frontend/src/services/api.ts | 12 +- frontend/src/views/LifePostDetail.vue | 60 +++++++- frontend/src/views/LifeView.vue | 76 +++++++++- 7 files changed, 335 insertions(+), 16 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 66bba18..0de099d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -250,7 +250,8 @@ - Notifications 用于让已登录用户接收与自己相关的社区互动和审核结果。 - 通知持久化存储,用户离线期间产生的通知会在下次登录后继续可见。 -- 通知实时推送可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。 +- 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。 +- AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态应通过 WebSocket 直接更新,不要求用户刷新页面。 - 通知范围: - Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。 - Life Comment 收到审核通过后的回复时,通知父评论作者。 @@ -373,6 +374,7 @@ - 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。 - 新增或更新审核目标时先进入不可公开状态;只有 AI 审核通过后才进入普通公开讨论列表。 - 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。 +- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。 - AI 审核会自动识别评论适合的语言区,语言区使用启用状态的 `languages.code`,但不影响系统 UI 语言。 - 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。 - 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 @@ -835,6 +837,7 @@ Life Post 可配置: - 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。 - Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。 - 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。 +- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 API 暴露边界: diff --git a/backend/src/notifications.ts b/backend/src/notifications.ts index 0959a31..3cddc3e 100644 --- a/backend/src/notifications.ts +++ b/backend/src/notifications.ts @@ -82,7 +82,13 @@ export type NotificationsPage = { type NotificationWsMessage = | { type: 'notifications.connected'; unreadCount: number } | { type: 'notifications.created'; notification: NotificationItem; unreadCount: number } - | { type: 'notifications.unread'; unreadCount: number }; + | { type: 'notifications.unread'; unreadCount: number } + | { + type: 'moderation.updated'; + target: NotificationTarget; + moderationStatus: NotificationModerationStatus; + moderationLanguageCode: string | null; + }; const defaultNotificationLimit = 15; const maxNotificationLimit = 50; @@ -267,6 +273,20 @@ async function publishUnreadCount(userId: number): Promise { }); } +async function publishModerationUpdate( + userId: number, + target: NotificationTarget, + moderationStatus: NotificationModerationStatus, + moderationLanguageCode: string | null +): Promise { + broadcastNotificationMessage(userId, { + type: 'moderation.updated', + target, + moderationStatus, + moderationLanguageCode + }); +} + async function publishInsertedNotification(row: { id: number; recipientUserId: number } | null): Promise { if (row) { await publishNotification(row.id, row.recipientUserId); @@ -539,7 +559,12 @@ export async function createModerationResultNotification( status: NotificationModerationStatus ): Promise { if (target.type === 'life-post') { - const row = await queryOne<{ id: number; recipientUserId: number }>( + const row = await queryOne<{ + id: number; + recipientUserId: number; + moderationLanguageCode: string | null; + lifePostId: number; + }>( ` INSERT INTO notifications ( recipient_user_id, @@ -553,16 +578,47 @@ export async function createModerationResultNotification( WHERE id = $1 AND deleted_at IS NULL AND created_by_user_id IS NOT NULL - RETURNING id, recipient_user_id AS "recipientUserId" + RETURNING + id, + recipient_user_id AS "recipientUserId", + ( + SELECT ai_moderation_language_code + FROM life_posts + WHERE id = $1 + ) AS "moderationLanguageCode", + life_post_id AS "lifePostId" `, [target.id, status] ); await publishInsertedNotification(row); + if (row) { + await publishModerationUpdate( + row.recipientUserId, + { + type: 'life-post', + id: row.lifePostId, + path: `/life/${row.lifePostId}`, + lifePostId: row.lifePostId, + lifeCommentId: null, + discussionCommentId: null, + entityType: null, + entityId: null + }, + status, + row.moderationLanguageCode + ); + } return; } if (target.type === 'life-comment') { - const row = await queryOne<{ id: number; recipientUserId: number }>( + const row = await queryOne<{ + id: number; + recipientUserId: number; + moderationLanguageCode: string | null; + lifePostId: number; + lifeCommentId: number; + }>( ` INSERT INTO notifications ( recipient_user_id, @@ -587,15 +643,48 @@ export async function createModerationResultNotification( AND lc.deleted_at IS NULL AND lp.deleted_at IS NULL AND lc.created_by_user_id IS NOT NULL - RETURNING id, recipient_user_id AS "recipientUserId" + RETURNING + id, + recipient_user_id AS "recipientUserId", + ( + SELECT ai_moderation_language_code + FROM life_post_comments + WHERE id = $1 + ) AS "moderationLanguageCode", + life_post_id AS "lifePostId", + life_comment_id AS "lifeCommentId" `, [target.id, status] ); await publishInsertedNotification(row); + if (row) { + await publishModerationUpdate( + row.recipientUserId, + { + type: 'life-comment', + id: row.lifeCommentId, + path: `/life/${row.lifePostId}`, + lifePostId: row.lifePostId, + lifeCommentId: row.lifeCommentId, + discussionCommentId: null, + entityType: null, + entityId: null + }, + status, + row.moderationLanguageCode + ); + } return; } - const row = await queryOne<{ id: number; recipientUserId: number }>( + const row = await queryOne<{ + id: number; + recipientUserId: number; + moderationLanguageCode: string | null; + discussionCommentId: number; + entityType: DiscussionEntityType; + entityId: number; + }>( ` INSERT INTO notifications ( recipient_user_id, @@ -620,11 +709,38 @@ export async function createModerationResultNotification( WHERE id = $1 AND deleted_at IS NULL AND created_by_user_id IS NOT NULL - RETURNING id, recipient_user_id AS "recipientUserId" + RETURNING + id, + recipient_user_id AS "recipientUserId", + ( + SELECT ai_moderation_language_code + FROM entity_discussion_comments + WHERE id = $1 + ) AS "moderationLanguageCode", + discussion_comment_id AS "discussionCommentId", + entity_type AS "entityType", + entity_id AS "entityId" `, [target.id, status] ); await publishInsertedNotification(row); + if (row) { + await publishModerationUpdate( + row.recipientUserId, + { + type: 'discussion-comment', + id: row.discussionCommentId, + path: discussionEntityPath(row.entityType, row.entityId) ?? '/', + lifePostId: null, + lifeCommentId: null, + discussionCommentId: row.discussionCommentId, + entityType: row.entityType, + entityId: row.entityId + }, + status, + row.moderationLanguageCode + ); + } } function wsFrame(data: Buffer, opcode = 0x1): Buffer { diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue index 86b1de9..8bcceec 100644 --- a/frontend/src/components/EntityDiscussionPanel.vue +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -8,13 +8,15 @@ import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../ import { api, getAuthToken, + moderationUpdateEvent, onAuthTokenChange, setAuthToken, type AiModerationStatus, type AuthUser, type DiscussionEntityType, type EntityDiscussionComment, - type Language + type Language, + type ModerationUpdateDetail } from '../services/api'; import Skeleton from './Skeleton.vue'; @@ -176,7 +178,7 @@ function canSeeModeration(comment: EntityDiscussionComment) { } function canRetryModeration(comment: EntityDiscussionComment) { - return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment); + return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment); } function moderationLabel(status: AiModerationStatus) { @@ -304,6 +306,59 @@ async function retryModeration(comment: EntityDiscussionComment) { } } +function updateDiscussionCommentModeration( + items: EntityDiscussionComment[], + commentId: number, + status: AiModerationStatus, + languageCode: string | null +): boolean { + for (const comment of items) { + if (comment.id === commentId) { + comment.moderationStatus = status; + comment.moderationLanguageCode = languageCode; + return true; + } + + if (updateDiscussionCommentModeration(comment.replies, commentId, status, languageCode)) { + return true; + } + } + + return false; +} + +function isModerationUpdateEvent(event: Event): event is CustomEvent { + return event instanceof CustomEvent && event.detail?.type === 'moderation.updated'; +} + +function handleModerationUpdate(event: Event) { + if (!isModerationUpdateEvent(event)) { + return; + } + + const { target, moderationStatus, moderationLanguageCode } = event.detail; + if ( + target.type !== 'discussion-comment' || + target.discussionCommentId === null || + target.entityType !== props.entityType || + target.entityId !== Number(props.entityId) + ) { + return; + } + + const updated = updateDiscussionCommentModeration( + comments.value, + target.discussionCommentId, + moderationStatus, + moderationLanguageCode + ); + if (updated) { + comments.value = [...comments.value]; + } else if (moderationStatus === 'approved') { + void loadDiscussion(); + } +} + function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean { for (const comment of rows) { if (comment.id === id) { @@ -361,6 +416,7 @@ watch(activeLanguageCode, () => { }); onMounted(() => { + window.addEventListener(moderationUpdateEvent, handleModerationUpdate); void loadCurrentUser(); void loadLanguages(); void loadDiscussion(); @@ -370,6 +426,7 @@ onMounted(() => { }); onUnmounted(() => { + window.removeEventListener(moderationUpdateEvent, handleModerationUpdate); removeAuthListener?.(); }); diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue index 0fc24f6..ba0089a 100644 --- a/frontend/src/components/NotificationBell.vue +++ b/frontend/src/components/NotificationBell.vue @@ -17,6 +17,7 @@ import { import { api, getAuthToken, + moderationUpdateEvent, notificationWebSocketUrl, type AuthUser, type LifeReactionType, @@ -140,9 +141,13 @@ async function connectNotifications() { return; } - unreadCount.value = message.unreadCount; + if ('unreadCount' in message) { + unreadCount.value = message.unreadCount; + } if (message.type === 'notifications.created') { upsertNotification(message.notification); + } else if (message.type === 'moderation.updated') { + window.dispatchEvent(new CustomEvent(moderationUpdateEvent, { detail: message })); } } catch { // Invalid socket payloads are ignored. diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 416cbbe..fce627e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -479,7 +479,17 @@ export interface NotificationWsTicket { export type NotificationWsMessage = | { type: 'notifications.connected'; unreadCount: number } | { type: 'notifications.created'; notification: NotificationItem; unreadCount: number } - | { type: 'notifications.unread'; unreadCount: number }; + | { type: 'notifications.unread'; unreadCount: number } + | { + type: 'moderation.updated'; + target: NotificationTarget; + moderationStatus: NotificationModerationStatus; + moderationLanguageCode: string | null; + }; + +export const moderationUpdateEvent = 'pokopia-moderation-update'; + +export type ModerationUpdateDetail = Extract; export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; diff --git a/frontend/src/views/LifePostDetail.vue b/frontend/src/views/LifePostDetail.vue index 3071598..f0841a1 100644 --- a/frontend/src/views/LifePostDetail.vue +++ b/frontend/src/views/LifePostDetail.vue @@ -27,13 +27,15 @@ import { import { api, getAuthToken, + moderationUpdateEvent, onAuthTokenChange, setAuthToken, type AiModerationStatus, type AuthUser, type LifeComment, type LifePost, - type LifeReactionType + type LifeReactionType, + type ModerationUpdateDetail } from '../services/api'; const { locale, t } = useI18n(); @@ -272,7 +274,7 @@ function moderationTone(status: AiModerationStatus) { } function canRetryModeration(currentPost: LifePost) { - return currentPost.moderationStatus !== 'approved' && canManage(currentPost); + return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost); } function replacePost(updatedPost: LifePost) { @@ -280,6 +282,58 @@ function replacePost(updatedPost: LifePost) { commentsTotal.value = updatedPost.commentCount; } +function updateLifeCommentModeration( + items: LifeComment[], + commentId: number, + status: AiModerationStatus, + languageCode: string | null +): boolean { + for (const comment of items) { + if (comment.id === commentId) { + comment.moderationStatus = status; + comment.moderationLanguageCode = languageCode; + return true; + } + + if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) { + return true; + } + } + + return false; +} + +function isModerationUpdateEvent(event: Event): event is CustomEvent { + return event instanceof CustomEvent && event.detail?.type === 'moderation.updated'; +} + +function handleModerationUpdate(event: Event) { + if (!isModerationUpdateEvent(event) || !post.value) { + return; + } + + const { target, moderationStatus, moderationLanguageCode } = event.detail; + if (target.type === 'life-post' && target.lifePostId === post.value.id) { + post.value = { + ...post.value, + moderationStatus, + moderationLanguageCode + }; + return; + } + + if (target.type !== 'life-comment' || target.lifePostId !== post.value.id || target.lifeCommentId === null) { + return; + } + + const updated = updateLifeCommentModeration(comments.value, target.lifeCommentId, moderationStatus, moderationLanguageCode); + if (updated) { + comments.value = [...comments.value]; + } else if (moderationStatus === 'approved') { + void loadComments(true); + } +} + async function retryPostModeration(currentPost: LifePost) { moderationBusyPostId.value = currentPost.id; const nextErrors = { ...moderationErrors.value }; @@ -558,6 +612,7 @@ watch(locale, () => { onMounted(() => { document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('keydown', closeReactionPickerFromKeyboard); + window.addEventListener(moderationUpdateEvent, handleModerationUpdate); void loadCurrentUser(); void loadPost(); removeAuthListener = onAuthTokenChange(() => { @@ -569,6 +624,7 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('click', closeReactionPickerFromDocument); document.removeEventListener('keydown', closeReactionPickerFromKeyboard); + window.removeEventListener(moderationUpdateEvent, handleModerationUpdate); removeAuthListener?.(); }); diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 45c0d1b..25836ca 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -34,6 +34,7 @@ import { import { api, getAuthToken, + moderationUpdateEvent, onAuthTokenChange, setAuthToken, type AiModerationStatus, @@ -43,7 +44,8 @@ import { type LifeCategory, type LifeComment, type LifePost, - type LifeReactionType + type LifeReactionType, + type ModerationUpdateDetail } from '../services/api'; type LifeCommentPageState = { @@ -574,7 +576,75 @@ function moderationTone(status: AiModerationStatus) { } function canRetryModeration(post: LifePost) { - return post.moderationStatus !== 'approved' && canManage(post); + return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post); +} + +function updateLifeCommentModeration( + items: LifeComment[], + commentId: number, + status: AiModerationStatus, + languageCode: string | null +): boolean { + for (const comment of items) { + if (comment.id === commentId) { + comment.moderationStatus = status; + comment.moderationLanguageCode = languageCode; + return true; + } + + if (updateLifeCommentModeration(comment.replies, commentId, status, languageCode)) { + return true; + } + } + + return false; +} + +function isModerationUpdateEvent(event: Event): event is CustomEvent { + return event instanceof CustomEvent && event.detail?.type === 'moderation.updated'; +} + +function handleModerationUpdate(event: Event) { + if (!isModerationUpdateEvent(event)) { + return; + } + + const { target, moderationStatus, moderationLanguageCode } = event.detail; + if (target.type === 'life-post' && target.lifePostId !== null) { + const currentPost = posts.value.find((post) => post.id === target.lifePostId); + if (!currentPost) { + return; + } + + const updatedPost = { + ...currentPost, + moderationStatus, + moderationLanguageCode + }; + if (!matchesCurrentFilters(updatedPost)) { + posts.value = posts.value.filter((post) => post.id !== updatedPost.id); + return; + } + posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post)); + return; + } + + if (target.type !== 'life-comment' || target.lifePostId === null || target.lifeCommentId === null) { + return; + } + + const currentPost = posts.value.find((post) => post.id === target.lifePostId); + if (!currentPost) { + return; + } + + const page = commentPage(currentPost); + const updated = updateLifeCommentModeration(page.items, target.lifeCommentId, moderationStatus, moderationLanguageCode); + if (updated) { + setCommentPage(currentPost.id, { ...page, items: [...page.items] }); + } else if (moderationStatus === 'approved' && areCommentsExpanded(currentPost.id)) { + void loadComments(currentPost, true); + } } async function retryPostModeration(post: LifePost) { @@ -1040,6 +1110,7 @@ watch(locale, () => { onMounted(() => { document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('keydown', closeReactionPickerFromKeyboard); + window.addEventListener(moderationUpdateEvent, handleModerationUpdate); void loadCurrentUser(); void loadLanguages(); void loadLifeCategories(); @@ -1053,6 +1124,7 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('click', closeReactionPickerFromDocument); document.removeEventListener('keydown', closeReactionPickerFromKeyboard); + window.removeEventListener(moderationUpdateEvent, handleModerationUpdate); disconnectFeedObserver(); removeAuthListener?.(); });