feat: 添加用户关注功能

- 新增 Follow 表和 User.bio 字段 (Prisma schema)
- 创建 follow 模块 (action-service-repository)
- 新增 FollowButton/FollowStats/UserList 组件
- 用户页面显示 bio、粉丝/关注数、关注按钮
- 新增 /users/[username]/followers 和 following 页面
- 添加 en-US/zh-CN i18n 翻译

⚠️ 需要运行: prisma migrate dev --name add_follow_and_bio
This commit is contained in:
2026-03-10 14:56:06 +08:00
parent abcae1b8d1
commit 683a4104ec
20 changed files with 940 additions and 145 deletions

View File

@@ -0,0 +1,33 @@
import { z } from "zod";
const schemaActionInputToggleFollow = z.object({
targetUserId: z.string().min(1),
});
const schemaActionInputGetFollowers = z.object({
userId: z.string().min(1),
page: z.number().int().min(1).optional().default(1),
limit: z.number().int().min(1).max(100).optional().default(20),
});
const schemaActionInputGetFollowing = z.object({
userId: z.string().min(1),
page: z.number().int().min(1).optional().default(1),
limit: z.number().int().min(1).max(100).optional().default(20),
});
const schemaActionInputGetFollowStatus = z.object({
targetUserId: z.string().min(1),
});
export type ActionInputToggleFollow = z.infer<typeof schemaActionInputToggleFollow>;
export type ActionInputGetFollowers = z.infer<typeof schemaActionInputGetFollowers>;
export type ActionInputGetFollowing = z.infer<typeof schemaActionInputGetFollowing>;
export type ActionInputGetFollowStatus = z.infer<typeof schemaActionInputGetFollowStatus>;
export {
schemaActionInputGetFollowers,
schemaActionInputGetFollowing,
schemaActionInputGetFollowStatus,
schemaActionInputToggleFollow,
};

View File

@@ -0,0 +1,84 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { validate } from "@/utils/validate";
import { serviceGetFollowers, serviceGetFollowing, serviceGetFollowStatus, serviceToggleFollow } from "./follow-service";
import {
schemaActionInputGetFollowers,
schemaActionInputGetFollowing,
schemaActionInputGetFollowStatus,
schemaActionInputToggleFollow,
} from "./follow-action-dto";
import { createLogger } from "@/lib/logger";
const log = createLogger("follow-action");
export async function actionToggleFollow(input: unknown) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
try {
const dto = validate(input, schemaActionInputToggleFollow);
const result = await serviceToggleFollow({
currentUserId: session.user.id,
targetUserId: dto.targetUserId,
});
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to toggle follow";
log.error("Failed to toggle follow", { error });
return { success: false, message };
}
}
export async function actionGetFollowStatus(input: unknown) {
const session = await auth.api.getSession({ headers: await headers() });
try {
const dto = validate(input, schemaActionInputGetFollowStatus);
const result = await serviceGetFollowStatus({
currentUserId: session?.user?.id || "",
targetUserId: dto.targetUserId,
});
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get follow status";
log.error("Failed to get follow status", { error });
return { success: false, message };
}
}
export async function actionGetFollowers(input: unknown) {
try {
const dto = validate(input, schemaActionInputGetFollowers);
const result = await serviceGetFollowers({
userId: dto.userId,
page: dto.page,
limit: dto.limit,
});
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get followers";
log.error("Failed to get followers", { error });
return { success: false, message };
}
}
export async function actionGetFollowing(input: unknown) {
try {
const dto = validate(input, schemaActionInputGetFollowing);
const result = await serviceGetFollowing({
userId: dto.userId,
page: dto.page,
limit: dto.limit,
});
return { success: true, data: result };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get following";
log.error("Failed to get following", { error });
return { success: false, message };
}
}

View File

@@ -0,0 +1,51 @@
export type RepoInputCreateFollow = {
followerId: string;
followingId: string;
};
export type RepoInputDeleteFollow = {
followerId: string;
followingId: string;
};
export type RepoInputCheckFollow = {
followerId: string;
followingId: string;
};
export type RepoInputGetFollowers = {
userId: string;
page?: number;
limit?: number;
};
export type RepoInputGetFollowing = {
userId: string;
page?: number;
limit?: number;
};
export type RepoOutputFollowUser = {
id: string;
followerId: string;
followingId: string;
createdAt: Date;
};
export type RepoOutputFollowWithUser = {
id: string;
followerId: string;
followingId: string;
createdAt: Date;
user: {
id: string;
username: string | null;
displayUsername: string | null;
image: string | null;
bio: string | null;
};
};
export type RepoOutputFollowerCount = number;
export type RepoOutputFollowingCount = number;
export type RepoOutputIsFollowing = boolean;

View File

@@ -0,0 +1,142 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import type {
RepoInputCheckFollow,
RepoInputCreateFollow,
RepoInputDeleteFollow,
RepoInputGetFollowers,
RepoInputGetFollowing,
RepoOutputFollowerCount,
RepoOutputFollowUser,
RepoOutputFollowWithUser,
RepoOutputFollowingCount,
RepoOutputIsFollowing,
} from "./follow-repository-dto";
const log = createLogger("follow-repository");
export async function repoCreateFollow(
dto: RepoInputCreateFollow
): Promise<RepoOutputFollowUser> {
log.debug("Creating follow", { followerId: dto.followerId, followingId: dto.followingId });
const follow = await prisma.follow.create({
data: {
followerId: dto.followerId,
followingId: dto.followingId,
},
});
log.info("Follow created", { followId: follow.id });
return follow;
}
export async function repoDeleteFollow(
dto: RepoInputDeleteFollow
): Promise<void> {
log.debug("Deleting follow", { followerId: dto.followerId, followingId: dto.followingId });
await prisma.follow.delete({
where: {
followerId_followingId: {
followerId: dto.followerId,
followingId: dto.followingId,
},
},
});
log.info("Follow deleted");
}
export async function repoCheckFollow(
dto: RepoInputCheckFollow
): Promise<RepoOutputIsFollowing> {
const follow = await prisma.follow.findUnique({
where: {
followerId_followingId: {
followerId: dto.followerId,
followingId: dto.followingId,
},
},
});
return !!follow;
}
export async function repoGetFollowersCount(userId: string): Promise<RepoOutputFollowerCount> {
return prisma.follow.count({
where: { followingId: userId },
});
}
export async function repoGetFollowingCount(userId: string): Promise<RepoOutputFollowingCount> {
return prisma.follow.count({
where: { followerId: userId },
});
}
export async function repoGetFollowers(
dto: RepoInputGetFollowers
): Promise<RepoOutputFollowWithUser[]> {
const { userId, page = 1, limit = 20 } = dto;
const skip = (page - 1) * limit;
const follows = await prisma.follow.findMany({
where: { followingId: userId },
include: {
follower: {
select: {
id: true,
username: true,
displayUsername: true,
image: true,
bio: true,
},
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
});
return follows.map((f) => ({
id: f.id,
followerId: f.followerId,
followingId: f.followingId,
createdAt: f.createdAt,
user: f.follower,
}));
}
export async function repoGetFollowing(
dto: RepoInputGetFollowing
): Promise<RepoOutputFollowWithUser[]> {
const { userId, page = 1, limit = 20 } = dto;
const skip = (page - 1) * limit;
const follows = await prisma.follow.findMany({
where: { followerId: userId },
include: {
following: {
select: {
id: true,
username: true,
displayUsername: true,
image: true,
bio: true,
},
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
});
return follows.map((f) => ({
id: f.id,
followerId: f.followerId,
followingId: f.followingId,
createdAt: f.createdAt,
user: f.following,
}));
}

View File

@@ -0,0 +1,51 @@
export type ServiceInputToggleFollow = {
currentUserId: string;
targetUserId: string;
};
export type ServiceInputGetFollowers = {
userId: string;
page?: number;
limit?: number;
};
export type ServiceInputGetFollowing = {
userId: string;
page?: number;
limit?: number;
};
export type ServiceInputCheckFollow = {
currentUserId: string;
targetUserId: string;
};
export type ServiceOutputFollowUser = {
id: string;
followerId: string;
followingId: string;
createdAt: Date;
};
export type ServiceOutputFollowWithUser = {
id: string;
createdAt: Date;
user: {
id: string;
username: string | null;
displayUsername: string | null;
image: string | null;
bio: string | null;
};
};
export type ServiceOutputFollowStatus = {
isFollowing: boolean;
followersCount: number;
followingCount: number;
};
export type ServiceOutputToggleFollow = {
isFollowing: boolean;
followersCount: number;
};

View File

@@ -0,0 +1,103 @@
import { createLogger } from "@/lib/logger";
import {
repoCheckFollow,
repoCreateFollow,
repoDeleteFollow,
repoGetFollowers,
repoGetFollowersCount,
repoGetFollowing,
repoGetFollowingCount,
} from "./follow-repository";
import type {
ServiceInputCheckFollow,
ServiceInputGetFollowers,
ServiceInputGetFollowing,
ServiceInputToggleFollow,
ServiceOutputFollowStatus,
ServiceOutputFollowWithUser,
ServiceOutputToggleFollow,
} from "./follow-service-dto";
const log = createLogger("follow-service");
export async function serviceToggleFollow(
dto: ServiceInputToggleFollow
): Promise<ServiceOutputToggleFollow> {
const { currentUserId, targetUserId } = dto;
if (currentUserId === targetUserId) {
throw new Error("Cannot follow yourself");
}
const isFollowing = await repoCheckFollow({
followerId: currentUserId,
followingId: targetUserId,
});
if (isFollowing) {
await repoDeleteFollow({
followerId: currentUserId,
followingId: targetUserId,
});
log.info("Unfollowed user", { currentUserId, targetUserId });
} else {
await repoCreateFollow({
followerId: currentUserId,
followingId: targetUserId,
});
log.info("Followed user", { currentUserId, targetUserId });
}
const followersCount = await repoGetFollowersCount(targetUserId);
return {
isFollowing: !isFollowing,
followersCount,
};
}
export async function serviceGetFollowStatus(
dto: ServiceInputCheckFollow
): Promise<ServiceOutputFollowStatus> {
const { currentUserId, targetUserId } = dto;
const [isFollowing, followersCount, followingCount] = await Promise.all([
currentUserId
? repoCheckFollow({ followerId: currentUserId, followingId: targetUserId })
: false,
repoGetFollowersCount(targetUserId),
repoGetFollowingCount(targetUserId),
]);
return {
isFollowing,
followersCount,
followingCount,
};
}
export async function serviceGetFollowers(
dto: ServiceInputGetFollowers
): Promise<{ followers: ServiceOutputFollowWithUser[]; total: number }> {
const { userId, page, limit } = dto;
const [followers, total] = await Promise.all([
repoGetFollowers({ userId, page, limit }),
repoGetFollowersCount(userId),
]);
return { followers, total };
}
export async function serviceGetFollowing(
dto: ServiceInputGetFollowing
): Promise<{ following: ServiceOutputFollowWithUser[]; total: number }> {
const { userId, page, limit } = dto;
const [following, total] = await Promise.all([
repoGetFollowing({ userId, page, limit }),
repoGetFollowingCount(userId),
]);
return { following, total };
}