diff --git a/DESIGN.md b/DESIGN.md index d9c4864..fa03d61 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,6 +127,10 @@ - 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。 - 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。 - 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。 + - 用户可 Follow 其他用户;Follow 是单向关系,双方互相 Follow 时在展示层视为 Friends。 + - Friend 不单独存储为独立关系,始终由双向 Follow 派生,避免双写不一致。 + - 公开 Profile 展示 Followers、Following 和 Friends 数量;登录用户查看其他用户 Profile 时可看到自己与对方的关系状态:未关注、已关注、被对方关注或 Friends。 + - 登录且邮箱已验证并拥有 `users.follow` 权限的用户可以 Follow / Unfollow 其他用户;用户不能 Follow 自己。 - Profile 的 Feeds 和 Reactions 中可从 Life Post 的 Reaction 汇总或 Reaction 活动打开公开 Reaction 用户列表 Modal。 - Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。 - Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。 @@ -256,6 +260,7 @@ - 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。 - AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态、语言区和可展示的审核原因详情应通过 WebSocket 直接更新,不要求用户刷新页面。 - 通知范围: + - 用户被别人 Follow 时,通知被 Follow 的用户;同一用户重复 Follow 同一目标时合并更新同一通知。 - Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。 - Life Comment 收到审核通过后的回复时,通知父评论作者。 - 实体讨论评论收到审核通过后的回复时,通知父评论作者。 @@ -277,6 +282,7 @@ - `updatedAt` - 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。 - 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。 +- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。 ## 滥用防护与限流 @@ -834,6 +840,7 @@ Life Post 可配置: - Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。 - Feed 支持 Rateable 筛选;All 表示不过滤,Rateable only 只展示可评分 Category 下的 Post。 - Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序;Top rated 按平均评分倒序,同分时按创建时间倒序。 +- 登录用户可切换 All Feed 和 Following Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 当前没有图片上传、转发或置顶。 - Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。 @@ -1003,13 +1010,16 @@ API 暴露边界: - `GET /api/recipes` - `GET /api/recipes/:id` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。 +- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 - `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。 - `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。 -- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。 +- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 - `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。 - `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。 +- `PUT /api/users/:id/follow`:需要 `users.follow`;Follow 指定用户并返回更新后的公开 Profile。 +- `DELETE /api/users/:id/follow`:需要 `users.follow`;Unfollow 指定用户并返回更新后的公开 Profile。 - `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。 认证 API: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 456b1f4..f0df433 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -94,6 +94,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx ON users(referred_by_user_id); +CREATE TABLE IF NOT EXISTS user_follows ( + follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (follower_user_id, followed_user_id), + CHECK (follower_user_id <> followed_user_id) +); + +CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx + ON user_follows(followed_user_id, created_at DESC, follower_user_id); + CREATE TABLE IF NOT EXISTS environments ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, @@ -290,6 +301,7 @@ VALUES ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), ('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true), ('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true), + ('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true), ('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true), ('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true), ('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true) @@ -381,6 +393,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[ 'life.comments.delete-any', 'life.reactions.set', 'life.ratings.set', + 'users.follow', 'discussions.comments.create', 'discussions.comments.delete', 'discussions.comments.delete-any' @@ -449,6 +462,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[ 'life.comments.delete', 'life.reactions.set', 'life.ratings.set', + 'users.follow', 'discussions.comments.create', 'discussions.comments.delete' ]) @@ -496,6 +510,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[ 'life.comments.delete', 'life.reactions.set', 'life.ratings.set', + 'users.follow', 'discussions.comments.create', 'discussions.comments.delete' ]) @@ -514,6 +529,13 @@ JOIN permissions p ON p.key = 'life.ratings.set' WHERE r.key IN ('admin', 'editor', 'member') ON CONFLICT DO NOTHING; +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = 'users.follow' +WHERE r.key IN ('admin', 'editor', 'member') +ON CONFLICT DO NOTHING; + WITH first_owner_user AS ( SELECT u.id FROM users u @@ -1231,10 +1253,12 @@ CREATE TABLE IF NOT EXISTS notifications ( 'life_comment_reply', 'discussion_comment_reply', 'life_post_reaction', + 'user_follow', 'moderation_result' ) ), life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE, + profile_user_id integer REFERENCES users(id) ON DELETE CASCADE, life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE, parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL, discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE, @@ -1274,6 +1298,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx ON notifications(recipient_user_id, actor_user_id, life_post_id) WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL; +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE; + +CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx + ON notifications(recipient_user_id, actor_user_id, profile_user_id) + WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS notification_ws_tickets ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1289,6 +1320,24 @@ CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx ALTER TABLE notifications ADD COLUMN IF NOT EXISTS moderation_reason text; +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications + ADD CONSTRAINT notifications_type_check CHECK ( + type IN ( + 'life_post_comment', + 'life_comment_reply', + 'discussion_comment_reply', + 'life_post_reaction', + 'user_follow', + 'moderation_result' + ) + ); + ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false; diff --git a/backend/src/notifications.ts b/backend/src/notifications.ts index bd7c26e..1c83ae9 100644 --- a/backend/src/notifications.ts +++ b/backend/src/notifications.ts @@ -13,10 +13,11 @@ type NotificationType = | 'life_comment_reply' | 'discussion_comment_reply' | 'life_post_reaction' + | 'user_follow' | 'moderation_result'; type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts'; -type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment'; +type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user'; type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment'; type NotificationCursor = { @@ -35,6 +36,7 @@ type NotificationRow = { actor: NotificationActor | null; type: NotificationType; lifePostId: number | null; + profileUserId: number | null; lifeCommentId: number | null; parentLifeCommentId: number | null; discussionCommentId: number | null; @@ -55,6 +57,7 @@ export type NotificationTarget = { id: number; path: string; lifePostId: number | null; + profileUserId: number | null; lifeCommentId: number | null; discussionCommentId: number | null; entityType: DiscussionEntityType | null; @@ -147,6 +150,7 @@ function notificationProjection(): string { n.recipient_user_id AS "recipientUserId", n.type, n.life_post_id AS "lifePostId", + n.profile_user_id AS "profileUserId", n.life_comment_id AS "lifeCommentId", n.parent_life_comment_id AS "parentLifeCommentId", n.discussion_comment_id AS "discussionCommentId", @@ -178,6 +182,9 @@ function discussionEntityPath(entityType: DiscussionEntityType | null, entityId: } function notificationTargetType(row: NotificationRow): NotificationTargetType { + if (row.profileUserId !== null) { + return 'profile-user'; + } if (row.discussionCommentId !== null) { return 'discussion-comment'; } @@ -188,6 +195,9 @@ function notificationTargetType(row: NotificationRow): NotificationTargetType { } function notificationPath(row: NotificationRow): string { + if (row.profileUserId !== null) { + return `/profile/${row.profileUserId}`; + } if (row.lifePostId !== null) { return `/life/${row.lifePostId}`; } @@ -198,7 +208,9 @@ function notificationPath(row: NotificationRow): string { function toNotificationItem(row: NotificationRow): NotificationItem { const targetType = notificationTargetType(row); const targetId = - targetType === 'discussion-comment' + targetType === 'profile-user' + ? row.profileUserId + : targetType === 'discussion-comment' ? row.discussionCommentId : targetType === 'life-comment' ? row.lifeCommentId @@ -213,6 +225,7 @@ function toNotificationItem(row: NotificationRow): NotificationItem { id: targetId ?? 0, path: notificationPath(row), lifePostId: row.lifePostId, + profileUserId: row.profileUserId, lifeCommentId: row.lifeCommentId, discussionCommentId: row.discussionCommentId, entityType: row.entityType, @@ -458,6 +471,43 @@ export async function createLifePostReactionNotification(postId: number, actorUs await publishInsertedNotification(row); } +export async function createUserFollowNotification(actorUserId: number, followedUserId: number): Promise { + const row = await queryOne<{ id: number; recipientUserId: number }>( + ` + INSERT INTO notifications ( + recipient_user_id, + actor_user_id, + type, + profile_user_id, + read_at, + created_at, + updated_at + ) + SELECT + followed_user.id, + actor_user.id, + 'user_follow', + actor_user.id, + NULL, + now(), + now() + FROM users actor_user + JOIN users followed_user ON followed_user.id = $2 + WHERE actor_user.id = $1 + AND actor_user.id <> followed_user.id + ON CONFLICT (recipient_user_id, actor_user_id, profile_user_id) + WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL + DO UPDATE SET read_at = NULL, + created_at = now(), + updated_at = now() + RETURNING id, recipient_user_id AS "recipientUserId" + `, + [actorUserId, followedUserId] + ); + + await publishInsertedNotification(row); +} + export async function createApprovedCommentNotification(target: { type: ModerationTargetType; id: number; @@ -613,6 +663,7 @@ export async function createModerationResultNotification( id: row.lifePostId, path: `/life/${row.lifePostId}`, lifePostId: row.lifePostId, + profileUserId: null, lifeCommentId: null, discussionCommentId: null, entityType: null, @@ -688,6 +739,7 @@ export async function createModerationResultNotification( id: row.lifeCommentId, path: `/life/${row.lifePostId}`, lifePostId: row.lifePostId, + profileUserId: null, lifeCommentId: row.lifeCommentId, discussionCommentId: null, entityType: null, @@ -764,6 +816,7 @@ export async function createModerationResultNotification( id: row.discussionCommentId, path: discussionEntityPath(row.entityType, row.entityId) ?? '/', lifePostId: null, + profileUserId: null, lifeCommentId: null, discussionCommentId: row.discussionCommentId, entityType: row.entityType, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index a1f551b..245fa38 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -16,7 +16,7 @@ import { requestAiModerationReview, type AiModerationStatus } from './aiModeration.ts'; -import { createLifePostReactionNotification } from './notifications.ts'; +import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts'; type QueryValue = string | string[] | undefined; @@ -353,6 +353,7 @@ type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostFilters = { authorId?: number; + followedByUserId?: number; }; type LifePostsPage = { @@ -395,9 +396,19 @@ type PublicProfileContribution = { lastContributedAt: Date | null; }; +type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends'; + +type PublicProfileSocial = { + followerCount: number; + followingCount: number; + friendCount: number; + viewerRelation: PublicProfileViewerRelation; +}; + type PublicUserProfile = { user: PublicProfileUser; stats: PublicProfileStats; + social: PublicProfileSocial; contributions: PublicProfileContribution[]; }; @@ -3535,6 +3546,18 @@ async function listLifePostsWithFilters( conditions.push(`lp.created_by_user_id = $${params.length}`); } + if (filters.followedByUserId !== undefined) { + params.push(filters.followedByUserId); + conditions.push(` + EXISTS ( + SELECT 1 + FROM user_follows uf + WHERE uf.follower_user_id = $${params.length} + AND uf.followed_user_id = lp.created_by_user_id + ) + `); + } + addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll); addModerationLanguageCondition(conditions, params, 'lp', languageCode); @@ -3616,6 +3639,15 @@ export async function listLifePosts( return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll); } +export async function listFollowingLifePosts( + userId: number, + paramsQuery: QueryParams = {}, + locale = defaultLocale, + canViewAll = false +): Promise { + return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll); +} + async function getPublicProfileUser(userIdValue: number): Promise { const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid'); return queryOne( @@ -3635,7 +3667,68 @@ function publicContributionType(entityType: string): string { return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType; } -export async function getPublicUserProfile(userIdValue: number): Promise { +async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise { + const social = await queryOne< + Omit & { + viewerFollows: boolean; + targetFollowsViewer: boolean; + } + >( + ` + SELECT + COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount", + COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount", + COALESCE(( + SELECT COUNT(*)::integer + FROM user_follows outgoing + WHERE outgoing.follower_user_id = $1 + AND EXISTS ( + SELECT 1 + FROM user_follows incoming + WHERE incoming.follower_user_id = outgoing.followed_user_id + AND incoming.followed_user_id = $1 + ) + ), 0) AS "friendCount", + CASE + WHEN $2::integer IS NULL OR $2::integer = $1 THEN false + ELSE EXISTS ( + SELECT 1 + FROM user_follows + WHERE follower_user_id = $2::integer + AND followed_user_id = $1 + ) + END AS "viewerFollows", + CASE + WHEN $2::integer IS NULL OR $2::integer = $1 THEN false + ELSE EXISTS ( + SELECT 1 + FROM user_follows + WHERE follower_user_id = $1 + AND followed_user_id = $2::integer + ) + END AS "targetFollowsViewer" + `, + [userId, viewerUserId] + ); + + const viewerRelation = + social?.viewerFollows && social.targetFollowsViewer + ? 'friends' + : social?.viewerFollows + ? 'following' + : social?.targetFollowsViewer + ? 'followed-by' + : 'none'; + + return { + followerCount: social?.followerCount ?? 0, + followingCount: social?.followingCount ?? 0, + friendCount: social?.friendCount ?? 0, + viewerRelation + }; +} + +export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise { const user = await getPublicProfileUser(userIdValue); if (!user) { return null; @@ -3702,6 +3795,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise ({ ...item, contentType: publicContributionType(item.contentType) @@ -3722,6 +3818,57 @@ export async function getPublicUserProfile(userIdValue: number): Promise { + const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid'); + if (followerUserId === followedUserId) { + throw validationError('server.validation.cannotFollowSelf'); + } + + const followedUser = await getPublicProfileUser(followedUserId); + if (!followedUser) { + return null; + } + + const inserted = await queryOne<{ inserted: boolean }>( + ` + INSERT INTO user_follows (follower_user_id, followed_user_id) + VALUES ($1, $2) + ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING + RETURNING true AS inserted + `, + [followerUserId, followedUser.id] + ); + + if (inserted?.inserted === true) { + await createUserFollowNotification(followerUserId, followedUser.id); + } + + return getPublicUserProfile(followedUser.id, followerUserId); +} + +export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise { + const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid'); + if (followerUserId === followedUserId) { + throw validationError('server.validation.cannotFollowSelf'); + } + + const followedUser = await getPublicProfileUser(followedUserId); + if (!followedUser) { + return null; + } + + await query( + ` + DELETE FROM user_follows + WHERE follower_user_id = $1 + AND followed_user_id = $2 + `, + [followerUserId, followedUser.id] + ); + + return getPublicUserProfile(followedUser.id, followerUserId); +} + export async function listUserLifePosts( userIdValue: number, paramsQuery: QueryParams = {}, diff --git a/backend/src/server.ts b/backend/src/server.ts index c0a8763..00e88a5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -64,6 +64,7 @@ import { exportAdminData, fetchPokemonData, fetchPokemonImageOptions, + followUser, getAdminDataToolsSummary, getAncientArtifact, getHabitat, @@ -81,6 +82,7 @@ import { listConfig, listDailyChecklistItems, listHabitats, + listFollowingLifePosts, listItems, listLifeComments, listLanguages, @@ -115,6 +117,7 @@ import { updateLifePost, updatePokemon, updateRecipe, + unfollowUser, wipeAdminData } from './queries.ts'; import { @@ -1184,7 +1187,30 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque app.get('/api/users/:id/profile', async (request, reply) => { const { id } = request.params as { id: string }; - const profile = await getPublicUserProfile(Number(id)); + const user = await optionalUser(request); + const profile = await getPublicUserProfile(Number(id), user?.id ?? null); + return profile ? { profile } : notFound(reply, request); +}); + +app.put('/api/users/:id/follow', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction'); + if (!user) { + return; + } + + const { id } = request.params as { id: string }; + const profile = await followUser(user.id, Number(id)); + return profile ? { profile } : notFound(reply, request); +}); + +app.delete('/api/users/:id/follow', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction'); + if (!user) { + return; + } + + const { id } = request.params as { id: string }; + const profile = await unfollowUser(user.id, Number(id)); return profile ? { profile } : notFound(reply, request); }); @@ -1239,6 +1265,20 @@ app.get('/api/life-posts', async (request) => { ); }); +app.get('/api/life-posts/following', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const canViewAll = userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any'); + return listFollowingLifePosts( + user.id, + request.query as Record, + requestLocale(request), + canViewAll + ); +}); + app.get('/api/life-posts/:id', async (request, reply) => { const { id } = request.params as { id: string }; const user = await optionalUser(request); diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue index 4f9c121..b7f348b 100644 --- a/frontend/src/components/NotificationBell.vue +++ b/frontend/src/components/NotificationBell.vue @@ -7,6 +7,7 @@ import { iconBell, iconCheck, iconComment, + iconProfile, iconReactionFun, iconReactionHelpful, iconReactionLike, @@ -264,7 +265,8 @@ function targetLabel(type: NotificationTargetType) { const labels: Record = { 'life-post': t('notifications.targetLifePost'), 'life-comment': t('notifications.targetLifeComment'), - 'discussion-comment': t('notifications.targetDiscussionComment') + 'discussion-comment': t('notifications.targetDiscussionComment'), + 'profile-user': t('notifications.targetProfile') }; return labels[type]; } @@ -285,6 +287,9 @@ function notificationText(notification: NotificationItem) { reaction: reactionLabel(notification.reactionType) }); } + if (notification.type === 'user_follow') { + return t('notifications.userFollow', { actor: actorName(notification) }); + } const target = targetLabel(notification.target.type); if (notification.moderationStatus === 'approved') { @@ -315,6 +320,9 @@ function notificationIcon(notification: NotificationItem) { if (notification.type === 'life_post_reaction') { return reactionIcon(notification.reactionType); } + if (notification.type === 'user_follow') { + return iconProfile; + } return notification.moderationStatus === 'approved' ? iconCheck : iconWarning; } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7bff351..7a5cebd 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -373,9 +373,10 @@ export type NotificationType = | 'life_comment_reply' | 'discussion_comment_reply' | 'life_post_reaction' + | 'user_follow' | 'moderation_result'; export type NotificationModerationStatus = Extract; -export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment'; +export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user'; export interface LifePost { id: number; @@ -467,6 +468,7 @@ export interface NotificationTarget { id: number; path: string; lifePostId: number | null; + profileUserId: number | null; lifeCommentId: number | null; discussionCommentId: number | null; entityType: DiscussionEntityType | null; @@ -585,9 +587,19 @@ export interface PublicProfileContribution { lastContributedAt: string | null; } +export type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends'; + +export interface PublicProfileSocial { + followerCount: number; + followingCount: number; + friendCount: number; + viewerRelation: PublicProfileViewerRelation; +} + export interface PublicUserProfile { user: PublicProfileUser; stats: PublicProfileStats; + social: PublicProfileSocial; contributions: PublicProfileContribution[]; } @@ -1119,6 +1131,21 @@ export const api = { markAllNotificationsRead: () => sendJson<{ unreadCount: number }>('/api/notifications/read-all', 'POST', {}), logout: () => postEmpty('/api/auth/logout'), publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`), + followUser: (id: string | number) => sendJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`, 'PUT', {}), + unfollowUser: (id: string | number) => deleteAndGetJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`), + followingLifePosts: (params: LifePostsParams = {}) => + getJson( + `/api/life-posts/following${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit, + search: params.search, + categoryId: params.categoryId, + language: params.language, + gameVersionId: params.gameVersionId, + rateable: params.rateable === null ? undefined : params.rateable, + sort: params.sort + })}` + ), userLifePosts: (id: string | number, params: ProfileActivityParams = {}) => getJson( `/api/users/${id}/life-posts${buildQuery({ diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 0681a9b..9e10616 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -6387,6 +6387,17 @@ button:disabled, grid-template-columns: repeat(4, minmax(0, 1fr)); } +.profile-stat-strip--social { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.profile-follow-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + .profile-stat-grid { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 3e3cbd9..6cabad2 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -61,6 +61,7 @@ type LifeCommentPageState = { }; type LifePostSort = 'latest' | 'oldest' | 'top-rated'; +type LifeFeedScope = 'all' | 'following'; const { locale, t } = useI18n(); const posts = ref([]); @@ -79,6 +80,7 @@ const activeLanguageCode = ref('all'); const activeGameVersionId = ref('all'); const activeRateableFilter = ref('all'); const activeSort = ref('latest'); +const activeFeedScope = ref('all'); const body = ref(''); const selectedCategoryId = ref(''); const selectedGameVersionId = ref(''); @@ -181,6 +183,10 @@ const sortOptions = computed>(() = { value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'top-rated', label: t('pages.life.sortTopRated') } ]); +const feedScopeOptions = computed(() => [ + { value: 'all', label: t('pages.life.allFeed') }, + { value: 'following', label: t('pages.life.followingFeed') } +]); const defaultLifeCategoryId = computed(() => { const category = lifeCategories.value.find((item) => item.isDefault); return category ? String(category.id) : ''; @@ -196,6 +202,7 @@ async function loadCurrentUser() { if (!getAuthToken()) { currentUser.value = null; + activeFeedScope.value = 'all'; authReady.value = true; return; } @@ -205,6 +212,7 @@ async function loadCurrentUser() { currentUser.value = response.user; } catch { currentUser.value = null; + activeFeedScope.value = 'all'; setAuthToken(null); } finally { authReady.value = true; @@ -265,7 +273,7 @@ async function loadPosts() { loadMorePaused.value = false; try { - const page = await api.lifePosts({ + const params = { limit: lifePostPageSize, search: searchQuery.value, categoryId: selectedFeedCategoryId.value, @@ -273,7 +281,8 @@ async function loadPosts() { gameVersionId: selectedFeedGameVersionId.value, rateable: selectedRateableFilter.value, sort: activeSort.value - }); + }; + const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); if (requestId !== postsRequestId) { return; } @@ -309,7 +318,7 @@ async function loadMorePosts() { loadError.value = ''; try { - const page = await api.lifePosts({ + const params = { cursor, limit: lifePostPageSize, search: searchQuery.value, @@ -318,7 +327,8 @@ async function loadMorePosts() { gameVersionId: selectedFeedGameVersionId.value, rateable: selectedRateableFilter.value, sort: activeSort.value - }); + }; + const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); if (requestId !== postsRequestId) { return; } @@ -447,7 +457,7 @@ async function submitPost() { replacePost(updated); } else { const created = await api.createLifePost(payload()); - if (activeSort.value !== 'latest') { + if (activeSort.value !== 'latest' || activeFeedScope.value === 'following') { void loadPosts(); } else if (matchesCurrentFilters(created)) { posts.value = [created, ...posts.value]; @@ -1205,6 +1215,9 @@ watch(activeRateableFilter, () => { watch(activeSort, () => { void loadPosts(); }); +watch(activeFeedScope, () => { + void loadPosts(); +}); watch(locale, () => { void loadLanguages(); void loadLifeCategories(); @@ -1220,8 +1233,10 @@ onMounted(() => { void loadLifeCategories(); void loadPosts(); removeAuthListener = onAuthTokenChange(() => { - void loadCurrentUser(); - void loadPosts(); + void (async () => { + await loadCurrentUser(); + await loadPosts(); + })(); }); }); @@ -1382,6 +1397,13 @@ onUnmounted(() => { @close="closeReactionUsersModal" /> + diff --git a/frontend/src/views/UserProfileView.vue b/frontend/src/views/UserProfileView.vue index 41e70bd..f7b9310 100644 --- a/frontend/src/views/UserProfileView.vue +++ b/frontend/src/views/UserProfileView.vue @@ -70,10 +70,12 @@ const commentFilter = ref('all'); const loading = ref(true); const busy = ref(false); const passwordBusy = ref(false); +const followBusy = ref(false); const message = ref(''); const errorMessage = ref(''); const passwordMessage = ref(''); const passwordErrorMessage = ref(''); +const followErrorMessage = ref(''); const referralSummaryMessage = ref(''); const referralSummaryErrorMessage = ref(''); const referralMessage = ref(''); @@ -111,6 +113,18 @@ const hasChanges = computed(() => { if (!user || !canShowAccount.value) return false; return trimmedDisplayName.value !== user.displayName; }); +const canFollowProfile = computed(() => { + const user = currentUser.value; + const target = profile.value?.user; + return Boolean(user && target && user.id !== target.id && user.permissions.includes('users.follow')); +}); +const followButtonLabel = computed(() => { + const relation = profile.value?.social.viewerRelation ?? 'none'; + if (relation === 'friends') return t('pages.profile.friend'); + if (relation === 'following') return t('pages.profile.following'); + if (relation === 'followed-by') return t('pages.profile.followBack'); + return t('pages.profile.follow'); +}); const profileInitial = computed(() => { const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || ''; return name.charAt(0).toUpperCase() || '#'; @@ -176,6 +190,14 @@ const communityStats = computed(() => { { label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 } ]; }); +const socialStats = computed(() => { + const social = profile.value?.social; + return [ + { label: t('pages.profile.followers'), value: social?.followerCount ?? 0 }, + { label: t('pages.profile.followingCount'), value: social?.followingCount ?? 0 }, + { label: t('pages.profile.friends'), value: social?.friendCount ?? 0 } + ]; +}); const filteredContributions = computed(() => { const items = profile.value?.contributions ?? []; if (contributionFilter.value === 'all') { @@ -280,6 +302,7 @@ async function loadProfile() { errorMessage.value = ''; passwordMessage.value = ''; passwordErrorMessage.value = ''; + followErrorMessage.value = ''; referralSummaryMessage.value = ''; referralSummaryErrorMessage.value = ''; referralMessage.value = ''; @@ -339,6 +362,27 @@ async function loadProfile() { } } +async function toggleFollow() { + const target = profile.value?.user; + if (!target || !canFollowProfile.value || followBusy.value) { + return; + } + + followErrorMessage.value = ''; + followBusy.value = true; + try { + const relation = profile.value?.social.viewerRelation ?? 'none'; + const response = relation === 'following' || relation === 'friends' + ? await api.unfollowUser(target.id) + : await api.followUser(target.id); + profile.value = response.profile; + } catch (error) { + followErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.followFailed'); + } finally { + followBusy.value = false; + } +} + async function saveProfile() { message.value = ''; errorMessage.value = ''; @@ -680,6 +724,14 @@ onMounted(() => { :tone="currentUser.emailVerified ? 'success' : 'warning'" /> +
+ + {{ followErrorMessage }} +
+
{{ item.label }}
@@ -687,6 +739,13 @@ onMounted(() => {
+ +
{{ t('pages.profile.referralCode') }} diff --git a/system-wordings.ts b/system-wordings.ts index 2ccc429..1976853 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -106,10 +106,12 @@ export const systemWordingMessages = { targetLifePost: 'Life post', targetLifeComment: 'Life comment', targetDiscussionComment: 'discussion comment', + targetProfile: 'profile', lifePostComment: '{actor} commented on your Life post', lifeCommentReply: '{actor} replied to your Life comment', discussionCommentReply: '{actor} replied to your discussion comment', lifePostReaction: '{actor} reacted {reaction} to your Life post', + userFollow: '{actor} followed you', moderationApproved: 'Your {target} passed review', moderationRejected: 'Your {target} did not pass review', moderationFailed: 'Review failed for your {target}' @@ -504,6 +506,14 @@ export const systemWordingMessages = { passwordSaved: 'Password updated', passwordSaveFailed: 'Password update failed', savePassword: 'Save password', + follow: 'Follow', + followBack: 'Follow back', + following: 'Following', + friend: 'Friend', + followers: 'Followers', + followingCount: 'Following', + friends: 'Friends', + followFailed: 'Follow action failed', joinedAt: 'Joined {date}', lifePosts: 'Life posts', lifeComments: 'Life comments', @@ -848,6 +858,9 @@ export const systemWordingMessages = { languages: 'Languages', allLanguages: 'All languages', allCategories: 'All', + feedScope: 'Feed scope', + allFeed: 'All feed', + followingFeed: 'Following', allVersions: 'All versions', versionFilter: 'Version', ratingFilter: 'Rating', @@ -1197,6 +1210,7 @@ export const systemWordingMessages = { invalidResetToken: 'The password reset link is invalid or expired', currentPasswordInvalid: 'Current password is incorrect', invalidReferralCode: 'Referral code is invalid', + cannotFollowSelf: 'You cannot follow yourself', emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.' }, validation: { @@ -1411,10 +1425,12 @@ export const systemWordingMessages = { targetLifePost: 'Life 动态', targetLifeComment: 'Life 评论', targetDiscussionComment: '讨论评论', + targetProfile: '个人主页', lifePostComment: '{actor} 评论了你的 Life 动态', lifeCommentReply: '{actor} 回复了你的 Life 评论', discussionCommentReply: '{actor} 回复了你的讨论评论', lifePostReaction: '{actor} 用 {reaction} Reaction 了你的 Life 动态', + userFollow: '{actor} 关注了你', moderationApproved: '你的{target}已审核通过', moderationRejected: '你的{target}未通过审核', moderationFailed: '你的{target}审核失败' @@ -1783,6 +1799,14 @@ export const systemWordingMessages = { passwordSaved: '密码已更新', passwordSaveFailed: '密码更新失败', savePassword: '保存密码', + follow: '关注', + followBack: '回关', + following: '已关注', + friend: '好友', + followers: '粉丝', + followingCount: '关注', + friends: '好友', + followFailed: '关注操作失败', joinedAt: '加入于 {date}', lifePosts: 'Life 动态', lifeComments: 'Life 评论', @@ -2127,6 +2151,9 @@ export const systemWordingMessages = { languages: '语言区', allLanguages: '全部语言', allCategories: '全部', + feedScope: '动态范围', + allFeed: '全部动态', + followingFeed: '关注动态', allVersions: '全部版本', versionFilter: '版本', ratingFilter: '评分', @@ -2474,9 +2501,10 @@ export const systemWordingMessages = { invalidCredentials: '邮箱或密码不正确', verifyEmailFirst: '请先完成邮箱验证', invalidResetToken: '密码重置链接无效或已过期', - currentPasswordInvalid: '当前密码不正确', - invalidReferralCode: '邀请码无效', - emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。' + currentPasswordInvalid: '当前密码不正确', + invalidReferralCode: '邀请码无效', + cannotFollowSelf: '不能关注自己', + emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。' }, validation: { nameRequired: '请输入名称',