用 Claude Code 实现安全的用户资料功能
用 Claude Code 构建用户资料:数据库、表单、校验、授权、头像上传和审计日志。
用户资料看起来只是显示名和头像,但真正上线后会牵涉到所有权、公开字段、图片上传限制、表单校验、权限判断和审计日志。如果只让 Claude Code “做一个个人资料页面”,它很容易先完成界面,却遗漏“只能修改自己的资料”“不能通过资料接口改角色”“不能把个人信息写进日志”等关键规则。
这篇文章用 Next.js App Router、Prisma、Zod、React 和 Sharp 作为示例,说明如何让 Claude Code 生成一个更接近生产可用的用户资料功能。重点不是让 AI 一次写出所有代码,而是先把边界、禁止项和验证步骤写清楚,再让 Claude Code 在这个框架内实现。
相关基础可以参考 Claude Code 认证实现 和 Claude Code 表单校验。安全资料建议阅读 OWASP Mass Assignment Cheat Sheet、OWASP File Upload Cheat Sheet、Next.js Route Handlers 与 Prisma relations。
功能范围和安全边界
本文实现的是:登录用户编辑自己的资料,并选择是否公开。可编辑字段包括用户名、显示名、简介、所在地、网站、社交链接、公开状态和头像 URL。邮箱、套餐、角色、团队权限和邮箱验证状态不属于资料功能。
认证是确认“你是谁”,授权是判断“你能做什么”。资料页面只应该处理“用户如何展示自己”,不能顺手处理管理员权限或付费状态。给 Claude Code 的任务里要把这条边界写出来。
| 领域 | 规则 | 目的 |
|---|---|---|
| 所有者 | 只更新 session.user.id | 防止修改他人资料 |
| 输入 | 只接受 Zod 允许的字段 | 防止 mass assignment |
| 返回值 | 只选择公开安全字段 | 避免泄露邮箱和内部 ID |
| 头像 | 校验 MIME、大小、像素并重新编码 | 降低不安全上传风险 |
| 审计日志 | 只记录字段名和最小元数据 | 兼顾可追踪性和隐私 |
flowchart TD
A["Profile form"] --> B["Zod validation"]
B --> C["Authorization check"]
C --> D["Prisma transaction"]
D --> E["Profile table"]
D --> F["ProfileAuditLog"]
A --> G["Avatar upload"]
G --> H["MIME / size / pixel checks"]
H --> I["Sharp resize to WebP"]
I --> E
数据库设计
把 User 和 Profile 分成一对一关系。User 保存身份、邮箱和账号状态,Profile 只保存展示信息。审计日志放到 ProfileAuditLog,用于记录谁在什么时候修改了哪些字段。
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
profile Profile?
profileAuditLogs ProfileAuditLog[] @relation("ProfileAuditActor")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Profile {
id String @id @default(cuid())
userId String @unique
username String @unique
displayName String
bio String @default("")
location String @default("")
websiteUrl String @default("")
avatarUrl String?
socialLinks Json @default("{}")
isPublic Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isPublic, updatedAt])
}
model ProfileAuditLog {
id String @id @default(cuid())
userId String
actorUserId String
action String
changedFields Json
metadata Json?
createdAt DateTime @default(now())
actor User @relation("ProfileAuditActor", fields: [actorUserId], references: [id])
@@index([userId, createdAt])
}
不要把 role、plan、isAdmin 放进 Profile。如果以后需要管理员代替成员编辑资料,也应做一个单独的管理员接口,并在审计日志中把管理员记录为 actorUserId。
用 Zod 限定输入
Zod 是请求数据进入数据库前的边界。这里使用 .strict(),让未知字段直接失败,而不是悄悄忽略或传给 Prisma。这样攻击者传入 role、emailVerified 或 userId 时,接口会返回错误。
// src/lib/profile-schema.ts
import { z } from "zod";
const usernamePattern = /^[a-z0-9][a-z0-9_-]{2,29}$/;
function isHttpUrl(value: string) {
if (value === "") return true;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
const optionalHttpUrl = z
.string()
.trim()
.max(200)
.refine(isHttpUrl, "Use http or https URLs only");
export const profileInputSchema = z
.object({
username: z
.string()
.trim()
.regex(usernamePattern, "Use 3-30 lowercase letters, numbers, _ or -"),
displayName: z.string().trim().min(1).max(40),
bio: z.string().trim().max(280).default(""),
location: z.string().trim().max(80).default(""),
websiteUrl: optionalHttpUrl.default(""),
socialLinks: z
.object({
github: optionalHttpUrl.default(""),
x: optionalHttpUrl.default(""),
linkedin: optionalHttpUrl.default(""),
})
.default({}),
isPublic: z.boolean().default(false),
})
.strict();
export type ProfileInput = z.infer<typeof profileInputSchema>;
用户名会出现在公开 URL 中,所以限制为小写字母、数字、下划线和连字符。显示名只是普通文本,不作为 HTML 渲染。社交链接可以为空,但只要填写就必须是 http 或 https。
更新 API 只修改本人资料
Route Handler 必须在服务端读取 session,并把更新目标固定为 session.user.id。请求体里的 userId 不可信,也不应该出现在 schema 中。保存资料和写审计日志应放在一个事务里,避免资料更新成功但日志丢失。
// src/app/api/profile/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { profileInputSchema } from "@/lib/profile-schema";
const publicProfileSelect = {
username: true,
displayName: true,
bio: true,
location: true,
websiteUrl: true,
avatarUrl: true,
socialLinks: true,
isPublic: true,
updatedAt: true,
} as const;
function changedKeys(before: Record<string, unknown> | null, after: Record<string, unknown>) {
if (!before) return Object.keys(after);
return Object.keys(after).filter((key) => {
return JSON.stringify(before[key]) !== JSON.stringify(after[key]);
});
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const profile = await prisma.profile.findUnique({
where: { userId: session.user.id },
select: publicProfileSelect,
});
return NextResponse.json({ profile });
}
export async function PUT(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const json = await request.json().catch(() => null);
const parsed = profileInputSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid profile input", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const userId = session.user.id;
const input = parsed.data;
const before = await prisma.profile.findUnique({ where: { userId } });
const fields = changedKeys(before, input);
const profile = await prisma.$transaction(async (tx) => {
const saved = await tx.profile.upsert({
where: { userId },
update: input,
create: { userId, ...input },
select: publicProfileSelect,
});
if (fields.length > 0) {
await tx.profileAuditLog.create({
data: {
userId,
actorUserId: userId,
action: before ? "profile.update" : "profile.create",
changedFields: fields,
metadata: {
source: "profile-settings",
beforeDisplayName: before?.displayName ?? null,
},
},
});
}
return saved;
});
return NextResponse.json({ profile });
}
这里返回的是 publicProfileSelect,不会返回邮箱、内部账号状态或审计日志。审计日志记录了变更字段名,但没有把旧简介、旧地点、旧社交链接全文复制进去。日志本身也可能成为敏感数据仓库,所以默认要少记。
头像上传要限制并重新编码
头像上传不能只依赖文件扩展名或浏览器的 accept 属性。服务端要检查 MIME、字节大小和图片尺寸,再用 Sharp 统一转换为 WebP。下面示例为了方便本地测试,把文件写到 public/uploads/avatars;生产环境可以替换为 S3、R2 或 GCS。
// src/app/api/profile/avatar/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs";
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_PIXELS = 4096 * 4096;
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("avatar");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Avatar file is required" }, { status: 400 });
}
if (!allowedTypes.has(file.type)) {
return NextResponse.json({ error: "Use JPEG, PNG, or WebP" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Avatar must be 2MB or smaller" }, { status: 400 });
}
const input = Buffer.from(await file.arrayBuffer());
const image = sharp(input, { limitInputPixels: MAX_PIXELS });
const metadata = await image.metadata();
if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64) {
return NextResponse.json({ error: "Image must be at least 64x64 pixels" }, { status: 400 });
}
const output = await sharp(input, { limitInputPixels: MAX_PIXELS })
.rotate()
.resize(256, 256, { fit: "cover" })
.webp({ quality: 82 })
.toBuffer();
const fileName = `${session.user.id}-${randomUUID()}.webp`;
const uploadDir = path.join(process.cwd(), "public", "uploads", "avatars");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, fileName), output);
const avatarUrl = `/uploads/avatars/${fileName}`;
await prisma.$transaction(async (tx) => {
await tx.profile.update({
where: { userId: session.user.id },
data: { avatarUrl },
});
await tx.profileAuditLog.create({
data: {
userId: session.user.id,
actorUserId: session.user.id,
action: "profile.avatar.update",
changedFields: ["avatarUrl"],
metadata: { contentType: "image/webp", bytes: output.byteLength },
},
});
});
return NextResponse.json({ avatarUrl });
}
生产环境还要考虑旧头像删除、签名 URL、恶意文件扫描、CDN 缓存和上传失败重试。Claude Code 可以继续实现这些细节,但前提是先写清楚存储策略。
React 表单
资料 JSON 和头像上传分成两个请求,错误处理会更清楚。头像失败不会丢失文本编辑,文本校验失败也不需要重新上传图片。保存成功后用服务端返回值更新状态,避免“界面显示已公开,但服务端实际拒绝”的乐观更新不一致。
// src/components/ProfileForm.tsx
"use client";
import { useState, useTransition } from "react";
import type { ProfileInput } from "@/lib/profile-schema";
type ProfileFormProps = {
initialProfile: ProfileInput & { avatarUrl?: string | null };
};
export function ProfileForm({ initialProfile }: ProfileFormProps) {
const [form, setForm] = useState<ProfileInput>(initialProfile);
const [avatarUrl, setAvatarUrl] = useState(initialProfile.avatarUrl ?? "");
const [message, setMessage] = useState("");
const [isPending, startTransition] = useTransition();
function updateField<K extends keyof ProfileInput>(key: K, value: ProfileInput[K]) {
setForm((current) => ({ ...current, [key]: value }));
}
async function uploadAvatar(file: File) {
setMessage("");
if (file.size > 2 * 1024 * 1024) {
setMessage("Avatar must be 2MB or smaller.");
return;
}
const body = new FormData();
body.append("avatar", file);
const response = await fetch("/api/profile/avatar", {
method: "POST",
body,
});
const result = await response.json();
if (!response.ok) {
setMessage(result.error ?? "Avatar upload failed.");
return;
}
setAvatarUrl(result.avatarUrl);
setMessage("Avatar updated.");
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setMessage("");
startTransition(async () => {
const response = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (!response.ok) {
setMessage(result.error ?? "Profile save failed.");
return;
}
setForm(result.profile);
setMessage("Profile saved.");
});
}
return (
<form onSubmit={submit} className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center gap-4">
<img
src={avatarUrl || "/images/default-avatar.png"}
alt=""
className="h-20 w-20 rounded-full object-cover"
/>
<label className="block">
<span className="text-sm font-medium">Avatar image</span>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadAvatar(file);
}}
className="mt-2 block text-sm"
/>
</label>
</div>
<label className="block">
<span className="text-sm font-medium">Username</span>
<input
value={form.username}
onChange={(event) => updateField("username", event.target.value as ProfileInput["username"])}
className="mt-1 w-full rounded border px-3 py-2"
autoComplete="off"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Display name</span>
<input
value={form.displayName}
onChange={(event) => updateField("displayName", event.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
maxLength={40}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Bio</span>
<textarea
value={form.bio}
onChange={(event) => updateField("bio", event.target.value)}
className="mt-1 h-28 w-full rounded border px-3 py-2"
maxLength={280}
/>
<span className="text-xs text-slate-500">{form.bio.length}/280</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={form.isPublic}
onChange={(event) => updateField("isPublic", event.target.checked)}
/>
<span>Show this profile publicly</span>
</label>
<button
type="submit"
disabled={isPending}
className="rounded bg-slate-900 px-4 py-2 font-medium text-white disabled:bg-slate-400"
>
{isPending ? "Saving..." : "Save profile"}
</button>
{message && <p className="text-sm text-slate-700">{message}</p>}
</form>
);
}
实际产品还应加入字段级错误、用户名重复提示、上传前预览、键盘焦点处理和禁用态测试。前端体验可以逐步打磨,但不能替代服务端校验。
给 Claude Code 的实现和审查提示词
实现提示词要说明编辑范围、技术栈和禁止项。安全审查提示词要让 Claude Code 以批判角度检查自己的差异。
Implement a user profile feature in this Next.js App Router project.
Scope:
- Edit only the files needed for profile schema, API routes, avatar upload, and ProfileForm.
- Use Prisma for Profile and ProfileAuditLog.
- Use Zod for request validation.
- Never accept userId, email, role, plan, or emailVerified from the request body.
- Use session.user.id as the only profile owner.
- Return only public-safe profile fields from API responses.
- Limit avatars to JPEG, PNG, or WebP, 2MB max, then resize to 256x256 WebP.
- Create an audit log row for profile and avatar changes.
After implementation, review your own diff for:
- mass assignment
- broken authorization
- unsafe file upload
- PII in logs
- optimistic UI inconsistency
- missing tests or manual verification steps
这段提示词的核心是“不要做什么”。用户资料涉及身份、权限和个人信息,Claude Code 必须知道哪些字段永远不能从请求体进入数据库。
典型用例
第一个用例是 SaaS 账号资料。用户可以编辑头像、显示名、部门和简介,但套餐、角色和账单状态必须走独立流程。资料接口离账号设置很近,因此最容易出现 mass assignment。
第二个用例是团队管理。管理员可以查看成员资料,但角色调整应该使用管理接口。如果管理员代替成员修改资料,审计日志中 actorUserId 是管理员,userId 是被修改的成员。
第三个用例是新用户引导。首次登录后让用户设置用户名和显示名,有助于邀请队友和展示公开页。但邮箱验证、条款同意和营销订阅不能和资料保存混为一谈。
第四个用例是公开资料页。只有 isPublic 为 true 的资料才显示在 /users/[username]。公开页不能返回邮箱、内部 ID、团队隐私或审计日志。
常见失败和陷阱
陷阱一是从请求中接受目标用户 ID。自助资料接口应始终使用 session 中的用户 ID,而不是请求体或查询参数中的用户 ID。
陷阱二是把 ...body 直接传给 Prisma。现在模型里没有敏感字段,不代表以后迁移时不会加入。严格 schema 是更稳的边界。
陷阱三是只靠前端文件选择器限制图片。攻击者可以绕过浏览器。必须在服务端检查大小、类型、像素并重新编码。
陷阱四是日志记录过多个人信息。旧简介、旧链接和旧地址都可能是隐私数据。审计日志先记录“谁改了哪些字段和时间”,不要复制整份资料。
陷阱五是乐观更新不一致。公开状态、用户名、头像 URL 这类会影响其他页面的字段,应以服务端返回结果为准。
变现和运营
资料功能会影响收入。SaaS 中完整的团队资料会提高邀请信任感;社区或市场中公开资料会影响转化;培训业务中讲师和学员资料会影响咨询意愿。资料页面粗糙,会让用户在真正询价前就离开。
ClaudeCodeLab 可以帮助团队用 Claude Code 实现认证、资料、管理画面、审计日志和相邻的产品功能。如果你要把资料功能加入现有产品,或想让团队学会审查 Claude Code 产出的安全边界,可以查看 Claude Code 培训与咨询。
上线后建议观察资料完成率、头像上传失败率、用户名重复率、公开资料访问量和 CTA 点击。审计日志主要用于支持与安全调查,不要把个人信息直接塞进事件名或日志正文。
实际测试结果
Masa 在一个小型 Next.js 验证应用中测试了这套流程。先写清数据库边界和禁止字段,再让 Claude Code 实现,代码审查明显更轻松。相反,只说“做一个好看的资料页”时,试作品把 userId 放进了表单状态,图片限制只在前端,审计日志还保存了完整旧简介。
发布前请测试:请求体中混入其他 userId 是否无效,发送 role 和 emailVerified 是否返回 400,超过 2MB 的图片是否被拒绝,javascript: 链接是否无法保存,用户名重复是否有错误提示,以及保存失败时 UI 是否不会显示成功。Claude Code 能提高速度,但资料功能的质量仍取决于明确的所有权、验证、日志和公开展示检查。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。