Use Cases (更新: 2026/6/2)

用 Claude Code 实现安全的用户资料功能

用 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 SheetOWASP File Upload Cheat SheetNext.js Route HandlersPrisma 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

数据库设计

UserProfile 分成一对一关系。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])
}

不要把 roleplanisAdmin 放进 Profile。如果以后需要管理员代替成员编辑资料,也应做一个单独的管理员接口,并在审计日志中把管理员记录为 actorUserId

用 Zod 限定输入

Zod 是请求数据进入数据库前的边界。这里使用 .strict(),让未知字段直接失败,而不是悄悄忽略或传给 Prisma。这样攻击者传入 roleemailVerifieduserId 时,接口会返回错误。

// 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 渲染。社交链接可以为空,但只要填写就必须是 httphttps

更新 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 是否无效,发送 roleemailVerified 是否返回 400,超过 2MB 的图片是否被拒绝,javascript: 链接是否无法保存,用户名重复是否有错误提示,以及保存失败时 UI 是否不会显示成功。Claude Code 能提高速度,但资料功能的质量仍取决于明确的所有权、验证、日志和公开展示检查。

#Claude Code #user profile #authentication #security #React
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。