feat(moderation): add AI moderation for user-generated content

Add AI moderation settings, caching, and status tracking
Require AI approval for Life Posts, Comments, and Discussions
Implement language filtering and moderation status UI
Add retry mechanism for failed moderation checks
This commit is contained in:
2026-05-03 17:08:51 +08:00
parent 590bd6a0ae
commit 18baf7b513
12 changed files with 2217 additions and 102 deletions

View File

@@ -245,10 +245,13 @@ export interface DailyChecklistItem {
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
export interface LifePost {
id: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
@@ -271,11 +274,13 @@ export interface LifePostsParams {
limit?: number;
search?: string;
tagId?: string | number;
language?: string;
}
export interface CommentPageParams {
cursor?: string | null;
limit?: number;
language?: string;
}
export interface LifeComment {
@@ -284,6 +289,8 @@ export interface LifeComment {
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
@@ -552,10 +559,12 @@ export interface DailyChecklistPayload {
export interface LifePostPayload {
body: string;
tagIds: number[];
languageCode?: string | null;
}
export interface LifeCommentPayload {
body: string;
languageCode?: string | null;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
@@ -567,6 +576,8 @@ export interface EntityDiscussionComment {
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
@@ -601,6 +612,33 @@ export interface UserCommentActivityPage {
export interface EntityDiscussionCommentPayload {
body: string;
languageCode?: string | null;
}
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
export interface AiModerationSettings {
enabled: boolean;
apiFormat: AiModerationApiFormat;
authMode: AiModerationAuthMode;
endpoint: string;
model: string;
requestsPerMinute: number;
apiKeyConfigured: boolean;
updatedAt: string;
updatedBy: UserSummary | null;
}
export interface AiModerationSettingsPayload {
enabled: boolean;
apiFormat: AiModerationApiFormat;
authMode: AiModerationAuthMode;
endpoint: string;
model: string;
requestsPerMinute: number;
apiKey?: string;
clearApiKey?: boolean;
}
export function buildQuery(params: Record<string, string | number | undefined>): string {
@@ -773,6 +811,9 @@ export const api = {
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
@@ -835,12 +876,15 @@ export const api = {
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search?.trim(),
tagId: params.tagId
tagId: params.tagId,
language: params.language
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
retryLifePostModeration: (id: string | number) =>
sendJson<LifePost>(`/api/life-posts/${id}/moderation/retry`, 'POST', {}),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
@@ -851,17 +895,21 @@ export const api = {
getJson<LifeCommentsPage>(
`/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
limit: params.limit,
language: params.language
})}`
),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
retryLifeCommentModeration: (id: string | number) =>
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
limit: params.limit,
language: params.language
})}`
),
createEntityDiscussionComment: (
@@ -875,6 +923,8 @@ export const api = {
commentId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
retryEntityDiscussionModeration: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: (
entityType: ImageUploadEntityType,