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:
33
src/modules/follow/follow-action-dto.ts
Normal file
33
src/modules/follow/follow-action-dto.ts
Normal 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,
|
||||
};
|
||||
84
src/modules/follow/follow-action.ts
Normal file
84
src/modules/follow/follow-action.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
51
src/modules/follow/follow-repository-dto.ts
Normal file
51
src/modules/follow/follow-repository-dto.ts
Normal 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;
|
||||
142
src/modules/follow/follow-repository.ts
Normal file
142
src/modules/follow/follow-repository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
51
src/modules/follow/follow-service-dto.ts
Normal file
51
src/modules/follow/follow-service-dto.ts
Normal 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;
|
||||
};
|
||||
103
src/modules/follow/follow-service.ts
Normal file
103
src/modules/follow/follow-service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user