feat(users): implement user following system and following feed

Add follow/unfollow actions and social stats to user profiles
Introduce Following feed scope in Life view
Add notifications for new followers
This commit is contained in:
2026-05-04 15:49:57 +08:00
parent 016364a8b8
commit 8cb8190554
11 changed files with 472 additions and 18 deletions

View File

@@ -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 @@
- 通知和审核状态实时更新可以走 WebSocketWebSocket 连接使用短期一次性 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 FeedFollowing 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

View File

@@ -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;

View File

@@ -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<void> {
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,

View File

@@ -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<LifePostsPage> {
return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
}
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
return queryOne<PublicProfileUser>(
@@ -3635,7 +3667,68 @@ function publicContributionType(entityType: string): string {
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
}
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
const social = await queryOne<
Omit<PublicProfileSocial, 'viewerRelation'> & {
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<PublicUserProfile | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
@@ -3702,6 +3795,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
[user.id]
);
const social = await getPublicProfileSocial(user.id, viewerUserId);
return {
user,
stats: stats ?? {
@@ -3715,6 +3810,7 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
lifeReactions: 0,
discussionComments: 0
},
social,
contributions: contributions.map((item) => ({
...item,
contentType: publicContributionType(item.contentType)
@@ -3722,6 +3818,57 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
};
}
export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
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<PublicUserProfile | null> {
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 = {},

View File

@@ -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<string, string | string[] | undefined>,
requestLocale(request),
canViewAll
);
});
app.get('/api/life-posts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);

View File

@@ -7,6 +7,7 @@ import {
iconBell,
iconCheck,
iconComment,
iconProfile,
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
@@ -264,7 +265,8 @@ function targetLabel(type: NotificationTargetType) {
const labels: Record<NotificationTargetType, string> = {
'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;
}

View File

@@ -373,9 +373,10 @@ export type NotificationType =
| 'life_comment_reply'
| 'discussion_comment_reply'
| 'life_post_reaction'
| 'user_follow'
| 'moderation_result';
export type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
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<LifePostsPage>(
`/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<LifePostsPage>(
`/api/users/${id}/life-posts${buildQuery({

View File

@@ -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));
}

View File

@@ -61,6 +61,7 @@ type LifeCommentPageState = {
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifeFeedScope = 'all' | 'following';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
@@ -79,6 +80,7 @@ const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest');
const activeFeedScope = ref<LifeFeedScope>('all');
const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref('');
@@ -181,6 +183,10 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [
{ 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"
/>
<Tabs
v-if="currentUser"
id="life-feed-scope"
v-model="activeFeedScope"
:tabs="feedScopeOptions"
:label="t('pages.life.feedScope')"
/>
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />

View File

@@ -70,10 +70,12 @@ const commentFilter = ref<CommentFilter>('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'"
/>
<div v-if="canFollowProfile" class="profile-follow-actions">
<button class="ui-button ui-button--blue" type="button" :disabled="followBusy" @click="toggleFollow">
<Icon :icon="iconReferral" class="ui-icon" aria-hidden="true" />
{{ followButtonLabel }}
</button>
<StatusMessage v-if="followErrorMessage" variant="danger" :duration="0">{{ followErrorMessage }}</StatusMessage>
</div>
<dl class="profile-stat-strip">
<div v-for="item in headlineStats" :key="item.label">
<dt>{{ item.label }}</dt>
@@ -687,6 +739,13 @@ onMounted(() => {
</div>
</dl>
<dl class="profile-stat-strip profile-stat-strip--social">
<div v-for="item in socialStats" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<div v-if="canShowAccount && referral" class="profile-referral-summary">
<div>
<span>{{ t('pages.profile.referralCode') }}</span>

View File

@@ -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: '评分',
@@ -2476,6 +2503,7 @@ export const systemWordingMessages = {
invalidResetToken: '密码重置链接无效或已过期',
currentPasswordInvalid: '当前密码不正确',
invalidReferralCode: '邀请码无效',
cannotFollowSelf: '不能关注自己',
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
},
validation: {