从 0 到 1 造一个在线简历编辑器:Next.js 16 + React 19 + Koa 全栈实战(附 PDF 实时预览不闪烁的完整方案)

前端工程

关键词:Next.js 16 / React 19 / @react-pdf/renderer / PDF 实时预览 / Tiptap / Koa2 / Sequelize / SEO 工程化 / 多语言 i18n

阅读时长:约 25 分钟 | 代码占比:40% | 适合人群:中高级前端、全栈工程师


写在前面

做一个「在线简历编辑器」,听起来是个 CRUD 项目:左边填表单,右边出预览,点个按钮下载 PDF。

我一开始也是这么想的。直到真正做下去,才发现坑一个比一个深:

  • 用户敲一个字,右边 PDF 要不要重新生成?生成一次 300ms,闪一下白屏,体验直接崩掉;
  • 浏览器打印出来的 PDF 和预览对不上,字体、行距、分页全乱;
  • 中文简历里夹几个英文单词,@react-pdf/renderer 直接把 CJK 字符断在了不该断的地方
  • 一个中文字体 TTF 动辄 10MB+,全量加载首屏直接 GG;
  • 简历分享链接用自增 ID,别人 id+1 就能遍历所有人的简历
  • 7 套模板 × 13 个模块 × 4 种语言,代码不抽象好,改一处崩全场。

这篇文章把 BeautyResumehttps://beautyresume.com)这个线上项目中踩过的坑和最终方案完整讲清楚。全文都是**可落地的真实代码**,不是 Demo 级伪代码。


一、技术选型:为什么是这套组合

1.1 整体架构

┌─────────────────────────────────────────────────────────┐
│                      浏览器 / 客户端                       │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │  表单编辑区   │→ │ Redux Store  │→ │  PDF 预览区    │  │
│  │   (Tiptap)   │  │ (RTK Slice)  │  │ (PDF.js Canvas)│ │
│  └──────────────┘  └──────────────┘  └───────────────┘  │
└──────────────────────────┬──────────────────────────────┘
                           │ HTTPS
┌──────────────────────────▼──────────────────────────────┐
│              Nginx(反向代理 / 静态资源 / Gzip)           │
└──────────┬────────────────────────────┬─────────────────┘
           │                            │
┌──────────▼──────────┐      ┌──────────▼─────────────────┐
│  Next.js 16 SSR     │      │   Koa 2 API Server         │
│  (standalone 产物)   │      │   (PM2 集群模式)            │
│  App Router         │      │   Session + CSRF + 限流     │
└─────────────────────┘      └──────────┬─────────────────┘
                                        │
                            ┌───────────▼──────────┐
                            │  MySQL 8 + Sequelize │
                            │  阿里云 OSS(静态/图片)│
                            └──────────────────────┘

1.2 技术栈清单

前端frontend/package.json):

领域 选型 版本 选它的理由
框架 Next.js 16.2.12 App Router + output: 'standalone',SEO 与部署体积双赢
UI 库 React 19.2.3 并发特性 + useTransition 对重渲染场景是刚需
状态 Redux Toolkit ^2.5.0 简历数据是深层嵌套大对象,需要细粒度订阅
PDF 生成 @react-pdf/renderer ^4.3.2 用 React 写 PDF,预览/导出可共用一套代码
PDF 渲染 react-pdf (PDF.js) ^10.4.1 自己控制 canvas 渲染,规避浏览器内置查看器
富文本 Tiptap ^3.20.4 ProseMirror 内核,输出结构化 JSON 而非脏 HTML
i18n next-intl ^4.13.4 与 App Router 的 [locale] 段天然契合
样式 Tailwind CSS ^4 原子化,配合 PDF 端的 StyleSheet 心智一致
安全 sanitize-html ^2.17.6 富文本 XSS 防线
E2E Playwright ^1.62.1 核心链路回归

后端backend/package.json):

领域 选型 理由
框架 Koa 2.16 洋葱模型对「统一日志 / 错误 / 响应格式」极友好
ORM Sequelize 6.37 参数化查询天然防注入
数据库 MySQL 8 (mysql2)
鉴权 koa-session 7 不用 JWT,理由见 3.2 节
校验 Joi 17 声明式 Schema,边界统一
密码 bcryptjs 3
验证码 svg-captcha 无需 canvas 原生依赖,部署省心

一个反直觉的选型:鉴权我们放弃了 JWT,回到 Session。原因很简单——简历是隐私数据,需要「改密码后全端立即下线」的能力。JWT 无状态的优点在这个场景反而是致命缺陷(签发出去就撤不回)。具体实现见 3.2 节。


二、核心难点一:PDF 实时预览如何做到「不闪、不卡、所见即所得」

这是整个项目技术含量最高的部分,也是最值得展开讲的。

2.1 先说清楚问题

在线简历编辑器有一个绕不开的矛盾:

  • 所见即所得要求预览就是最终 PDF,不能是「HTML 模拟一个 A4 纸」;
  • 真·生成 PDF 是个重操作,一份两页简历要 200~400ms;
  • 用户是连续输入的,每敲一个字触发一次生成,界面必然抖成帕金森。

很多同类产品的做法是「HTML 预览 + 导出时另走一套 PDF 生成」。这条路我们走过,结论是:两套代码必然对不齐。字体度量、行高计算、分页规则,只要有一处不同,用户下载的 PDF 就和他看到的不一样——这是简历产品的致命伤。

所以我们的方案是:预览和导出共用同一份 Document 代码,然后用工程手段把性能和闪烁问题解决掉。

2.2 唯一的 Document 入口

frontend/components/pdf/index.tsx

// PDF 简历文档 —— 预览与下载共用同一套代码
const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

/** 预览和下载 PDF 共用此组件,保证所见即所得 */
export function ResumeDocument({
  data,
  templateId,
  themeColor = DEFAULT_THEME_COLOR,
  pdfBodyPx = DEFAULT_PDF_BODY_PX,
  locale,
  labels,
}: ResumeDocumentProps) {
  ensurePdfFontsForRender(locale);          // 按语言按需注册字体子集
  const pdfFontFamily = resolvePdfFontFamily(locale);
  const resolvedId = resolvePdfTemplateId(templateId);
  const Template =
    pdfTemplateComponentById[resolvedId] ??
    pdfTemplateComponentById[defaultPdfTemplateId] ??
    ATemplate;

  return (
    <Document>
      <Template
        data={data}
        themeColor={themeColor}
        pdfBodyPx={pdfBodyPx}
        pdfFontFamily={pdfFontFamily}
        labels={labels}
      />
    </Document>
  );
}

预览页、下载按钮、缩略图生成、公开分享页——四个入口全部调用这一个组件。从架构上根绝了「预览与导出不一致」的可能。

2.3 为什么不用 <PDFViewer>

@react-pdf/renderer 官方提供了 <PDFViewer>,本质是把 blob 塞进 <iframe>,交给浏览器内置 PDF 查看器。

线上实测的问题:

  1. Chrome 内置查看器会强制加工具栏,还会自动缩放裁切,视觉上很不干净;
  2. 每次 blob 变更,iframe 整体重载,白屏一闪
  3. 移动端 Safari 行为完全不一致,几乎不可用。

所以我们改成:usePDF 拿到 blob URL → 交给 react-pdf(PDF.js)用 canvas 自己渲染

frontend/components/pdf/ResumePdfJsPreview.tsx

import { usePDF } from '@react-pdf/renderer';
import { Document as PdfJsDocument, Page, pdfjs } from 'react-pdf';

function ensurePdfWorker() {
  if (workerConfigured || typeof window === 'undefined') return;
  pdfjs.GlobalWorkerOptions.workerSrc = publicAssetUrl('/pdf.worker.min.mjs');
  workerConfigured = true;
}

export function ResumePdfJsPreview({ document: pdfDocument, surfaceColor = '#f0f3fd' }) {
  ensurePdfWorker();

  const [instance, updateInstance] = usePDF();
  useEffect(() => {
    updateInstance(pdfDocument as Parameters<typeof updateInstance>[0]);
  }, [pdfDocument, updateInstance]);
  // ...
}

部署细节:PDF.js 的 worker 文件必须能被独立访问。我们在 postinstall 钩子里加了 node scripts/copy-pdf-worker.js,把 worker 从 node_modules 拷到 public/,避免打包器处理 worker 时的各种玄学问题。

2.4 核心技巧:双缓冲 + 全页渲染完成才切换

这是消除闪烁的关键,思路来自图形学里的双缓冲(Double Buffering)。

朴素做法:新 blob 来了 → 直接替换 file prop → PDF.js 清空 canvas → 重新解析 → 逐页绘制。中间那段「清空到绘制完成」就是白屏闪烁

我们的做法

  1. 新 PDF 到达时,不动当前显示的图层;
  2. 在下面偷偷叠一个新图层(opacity: 0)开始渲染;
  3. 监听每一页的 onRenderSuccess,用 Set 计数;
  4. 集齐所有页才把新图层淡入、旧图层淡出;
  5. 260ms 淡出动画结束后回收旧图层内存。
const commitFrame = useCallback((url: string) => {
  if (currentUrlRef.current !== url) return;      // 已被更新的任务作废
  const previous = visibleUrlRef.current;
  if (previous === url) return;

  fadeTimersRef.current.forEach((timer) => window.clearTimeout(timer));
  fadeTimersRef.current = [];

  visibleUrlRef.current = url;
  setVisibleUrl(url);

  if (previous) {
    fadingUrlRef.current = previous;
    setFadingUrl(previous);
    setFrames((prev) => prev.filter((f) => f.url === url || f.url === previous));

    // 260ms 淡出后回收旧帧,防止内存泄漏
    const clearOldTimer = window.setTimeout(() => {
      setFrames((prev) => prev.filter((f) => f.url !== previous));
      setFadingUrl((current) =>
        current !== previous ? current : ((fadingUrlRef.current = null), null)
      );
      renderedPagesByUrlRef.current.delete(previous);
    }, FRAME_FADE_MS);
    fadeTimersRef.current = [clearOldTimer];
  }
}, []);

// 逐页计数,集齐所有页才 commit
const handlePageRenderSuccess = useCallback(
  (url: string, pageNumber: number, pages: number) => {
    if (currentUrlRef.current !== url || pages <= 0) return;
    let renderedPages = renderedPagesByUrlRef.current.get(url);
    if (!renderedPages) {
      renderedPages = new Set();
      renderedPagesByUrlRef.current.set(url, renderedPages);
    }
    renderedPages.add(pageNumber);
    if (renderedPages.size >= pages) commitFrame(url);
  },
  [commitFrame]
);

图层本体用 memo 包裹,并关闭文本层和注释层(预览不需要选中文字,关掉能省 30%+ 渲染时间):

const PdfFrameLayer = memo(function PdfFrameLayer({
  frame, pageWidth, visible, fading, onPageRenderSuccess,
}) {
  return (
    <div
      style={{
        opacity: visible ? 1 : 0,
        pointerEvents: 'none',
        transition: fading ? `opacity ${FRAME_FADE_MS}ms ease` : undefined,
        zIndex: fading ? 3 : visible ? 2 : 0,
      }}
      aria-hidden={!visible}
    >
      <PdfJsDocument file={frame.url} loading={null} error={null}>
        {Array.from({ length: frame.pages }, (_, i) => (
          <Page
            key={i}
            pageNumber={i + 1}
            width={pageWidth}
            renderTextLayer={false}         // 预览不需要选中文字
            renderAnnotationLayer={false}   // 也不需要注释
            onRenderSuccess={() => onPageRenderSuccess(frame.url, i + 1, frame.pages)}
          />
        ))}
      </PdfJsDocument>
    </div>
  );
});

关键点currentUrlRef 这个「令牌校验」非常重要。用户快速连续输入时会产生多个并发渲染任务,如果不校验,旧任务的回调可能覆盖新任务的结果,导致预览内容回退。这是一个典型的竞态条件(Race Condition),在异步渲染场景里必须显式处理。

2.5 防抖策略:输入停顿才重生成

双缓冲解决了「闪」,但没解决「算力浪费」。还需要在数据源头做节流:

// 输入停顿 400ms 后才触发 PDF 重建;
// 但模板 / 主题色 / 字号切换是「离散操作」,立即响应
const debouncedData = useDebouncedValue(resumeData, 400);

const pdfDocument = useMemo(
  () => (
    <ResumeDocument
      data={debouncedData}
      templateId={templateId}      // 不防抖
      themeColor={themeColor}      // 不防抖
      pdfBodyPx={pdfBodyPx}
      locale={locale}
      labels={labels}
    />
  ),
  [debouncedData, templateId, themeColor, pdfBodyPx, locale, labels]
);

设计哲学:区分「连续输入」和「离散操作」。用户敲字是连续的,防抖;用户点击换模板是离散的、有明确预期的,必须立即响应,否则会觉得「点了没反应」。


三、核心难点二:中文排版与字体体积

3.1 给 @react-pdf/textkit 打补丁修 CJK 断行

@react-pdf/renderer 的排版引擎 textkit 是按西文断词规则设计的:以空格为断点。而中文没有空格,一整段中文会被当成「一个超长单词」,结果就是:

  • 要么整段溢出纸张边界;
  • 要么在一个英文单词中间粗暴截断。

社区 issue 挂了很久没修,我们的方案是用 patch-package 直接打补丁

{
  "scripts": {
    "postinstall": "patch-package && node scripts/copy-pdf-worker.js"
  }
}

补丁核心思路是在换行机会(break opportunity)计算中,为 CJK 字符区间注入断点

// patches/@react-pdf+textkit+x.x.x.patch 核心逻辑
// CJK 统一表意文字 + 全角标点,每个字符后均可断行
const CJK_RANGE = /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u3000-\u303F]/;

// 且需处理「避头尾」规则:
// 行首禁则:,。、;:!?)》」』】…
// 行尾禁则:(《「『【

经验分享:遇到三方库 bug 不要盲目 fork 整个库来维护。patch-package 是最优解——补丁以 diff 形式提交进仓库,升级依赖时会明确报冲突提醒你复查,维护成本极低。这是我认为每个前端团队都该掌握的一个工程技巧

3.2 字体子集化:从 10MB 到 300KB

中文字体是 PDF 方案最大的体积杀手。思源黑体全量 CJK 有 16MB+,直接注册进去,用户首次生成 PDF 要等好几秒。

我们的三层优化:

第一层:按语言按需注册。 用户用英文界面写英文简历,根本不需要加载中文字体:

export function ensurePdfFontsForRender(locale: Locale) {
  if (registeredLocales.has(locale)) return;

  if (locale === 'zh-CN' || locale === 'zh-TW') {
    Font.register({
      family: 'NotoSansSC',
      fonts: [
        { src: fontUrl('NotoSansSC-Regular.subset.ttf'), fontWeight: 400 },
        { src: fontUrl('NotoSansSC-Bold.subset.ttf'), fontWeight: 700 },
      ],
    });
  } else if (locale === 'ja') {
    Font.register({ family: 'NotoSansJP', fonts: [...] });
  } else {
    Font.register({ family: 'Inter', fonts: [...] });   // 纯西文,体积极小
  }

  registeredLocales.add(locale);
}

第二层:字符集裁剪。 简历用字高度集中,用 fonttoolsGB2312 常用字集 + 常见 Emoji + 拉丁字母数字标点做子集:

pyftsubset NotoSansSC-Regular.otf \
  --unicodes-file=gb2312.txt \
  --output-file=NotoSansSC-Regular.subset.ttf \
  --flavor=woff2 --layout-features='*'

效果:16MB → 约 300KB,压缩 98%。

第三层:托管到 OSS + CDN,强缓存一年。 字体是典型的不可变资源,文件名带 hash,Cache-Control: max-age=31536000, immutable

补充说明:为什么不用系统字体?因为 PDF 要保证在任何设备上打开都一样。HR 用 Mac 预览、用 Windows Adobe Reader 打开、用手机微信打开,字体必须内嵌。这是简历这类「正式文档」的硬性要求。


四、核心难点三:模板系统怎么抽象才不会失控

7 套模板 × 13 个模块 × 4 种语言 × 主题色 × 字号,笛卡尔积一展开就是灾难。

4.1 三层抽象

┌─────────────────────────────────────────┐
│  Layer 3: 模板注册表(Registry)          │
│  声明式配置:id / 栏数 / ATS / tier      │
├─────────────────────────────────────────┤
│  Layer 2: 模板组件(A~G Template)        │
│  只负责「布局」:单栏?双栏?侧栏在左还是右?│
├─────────────────────────────────────────┤
│  Layer 1: 原子模块(13 个 Section)       │
│  基本信息 / 教育 / 工作 / 项目 / 技能 ...  │
│  样式全部由 props 注入,模块本身无主见     │
└─────────────────────────────────────────┘

4.2 注册表驱动

export const PDF_TEMPLATES: PdfTemplateMeta[] = [
  {
    id: 'A',
    slug: 'classic-simple-resume-template',
    columns: 1,
    atsFriendly: true,      // 单栏纯文本,ATS 解析友好
    tier: 'free',
  },
  {
    id: 'B',
    slug: 'two-column-professional-resume-template',
    columns: 2,
    atsFriendly: false,     // 双栏,部分 ATS 会读乱
    tier: 'pro',
  },
  // ...
];

const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

新增一套模板的成本:写一个布局组件 + 注册表加一条 + 一张缩略图。不需要动任何模块代码,不需要动预览逻辑,不需要动导出逻辑。

4.3 主题变量:字号联动的排版换算

用户调节「正文字号」(12~20px),如果只改正文,标题不动,整个版式的视觉层次就会崩。所以要做比例联动

export function buildTypography(pdfBodyPx: number) {
  const base = clamp(pdfBodyPx, 12, 20);
  return {
    body: base,
    small: round(base * 0.86),
    sectionTitle: round(base * 1.18),
    name: round(base * 2.1),
    lineHeight: base <= 13 ? 1.55 : base >= 18 ? 1.38 : 1.46,  // 字越大行高比越小
    sectionGap: round(base * 1.2),
    itemGap: round(base * 0.7),
  };
}

排版小知识:行高比例应该随字号反向变化。小字号需要更大的行高比(1.55)保证可读性,大字号则需要收紧(1.38),否则会显得松散。这是印刷排版的常识,但很多前端会写死 line-height: 1.5


五、后端:Koa 洋葱模型的正确打开方式

5.1 中间件装配顺序即架构

backend/app.js顺序是有讲究的,每一层的位置都有理由:

const Koa = require("koa");
require("./config/env");
const app = new Koa();

// 只有确认后端仅能经可信反代访问时,才信任转发 IP 头(防 IP 伪造绕过限流)
app.proxy = process.env.TRUST_PROXY === "1";

// 启动即校验密钥强度,配置错误就别启动,别等被打了才发现
const sessionSecret = String(process.env.SESSION_SECRET || "");
if (sessionSecret.length < 32) {
  throw new Error("SESSION_SECRET must contain at least 32 characters");
}
for (const name of ["VERIFICATION_CODE_SECRET", "LOG_HASH_SECRET", "WECHAT_WEB_STATE_SECRET"]) {
  const value = String(process.env[name] || "");
  if (value && value.length < 32) {
    throw new Error(`${name} must contain at least 32 characters when configured`);
  }
}

app.use(requestContext());   // ① 请求 ID + 结构化日志(最外层才能覆盖全链路耗时)
app.use(errorHandler());     // ② 统一错误捕获(紧贴外层,兜住内部所有抛错)

app.use(async (ctx, next) => {   // ③ 安全响应头
  ctx.set("X-Content-Type-Options", "nosniff");
  ctx.set("X-Frame-Options", "DENY");
  ctx.set("Referrer-Policy", "no-referrer");
  ctx.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  if (process.env.NODE_ENV === "production" && ctx.secure) {
    ctx.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
  }
  // 隐私接口禁止任何缓存
  if (ctx.path.startsWith("/private/") || ctx.path.startsWith("/public/user/")) {
    ctx.set("Cache-Control", "no-store");
  }
  await next();
});

app.use(globalLimiter);      // ④ 全局 IP 限流 120 req/min
app.use(serve(path.join(__dirname, "public"), { maxage: 365 * 24 * 60 * 60 * 1000 }));

app.use(cors({               // ⑤ CORS 严格白名单
  origin: (ctx) => {
    const origin = ctx.get("origin");
    if (!origin) return "";
    if (corsOrigins.length === 0) {
      return process.env.NODE_ENV === "production" ? "" : origin;  // 生产环境不裸奔
    }
    return corsOrigins.includes(origin) ? origin : "";
  },
  credentials: true,
}));

onerror(app);

app.use(bodyparser({         // ⑥ 请求体差异化限额
  enableTypes: ["json", "form", "text"],
  jsonLimit: `${maxResumeContentBytes + 64 * 1024}b`,   // 简历 JSON 需要大额度
  formLimit: "64kb",                                    // 表单收紧
  textLimit: "64kb",
}));
app.use(json());
app.use(csrfProtection());   // ⑦ CSRF(Origin / Referer / Sec-Fetch-Site 三重校验)

app.keys = [sessionSecret];
app.use(session({
  key: "beautyresume.sid",
  httpOnly: true,
  signed: true,
  sameSite: "lax",
  secure: process.env.SESSION_COOKIE_SECURE === "1" || process.env.NODE_ENV === "production",
  genid: () => crypto.randomUUID(),
  maxAge: 7 * 24 * 60 * 60 * 1000,
  renew: true,
  overwrite: true,
}, app));

app.use(async (ctx, next) => {  // ⑧ 路径前缀鉴权网关
  if (ctx.path.startsWith("/private")) {
    await requireLogin(ctx, next);
    return;
  }
  await next();
});

app.use(index.routes(), index.allowedMethods());  // ⑨ 业务路由

// ⑩ 强制 API 响应为 JSON —— 挂在路由「之后」
app.use(require("./middleware/jsonContentType")());

两个值得单独说的设计:

jsonContentType 为什么挂在路由后面?

这是整个中间件栈里唯一一个「先 await next() 再干活」的中间件,利用的是洋葱模型的回程阶段

// middleware/jsonContentType.js
module.exports = () => async (ctx, next) => {
  await next();                       // 先让路由跑完
  if (ctx.path.startsWith('/api') && ctx.type !== 'application/json') {
    ctx.type = 'application/json';    // 回程时统一覆写
  }
};

线上曾出现过:某个异常路径返回了 Koa 默认的 HTML 错误页,前端 JSON.parse 直接抛异常,整个页面白屏。加了这层「回程守卫」后,API 路径永远返回 JSON,前端可以放心解析。

这就是洋葱模型的精髓——同一个中间件可以同时在「请求进入」和「响应返回」两个时机干活。 很多人用 Koa 但只用了它的一半能力。

② 前缀网关鉴权而不是逐路由挂载

不在每个路由后面挂 requireLogin,而是用一个前缀中间件统一拦截 /private/*。好处是:新增私有接口不可能忘记加鉴权——把安全从「靠自觉」变成「靠架构」。路由表里只需标注更细粒度的 requireAdmin / requireEntitlement

5.2 为什么用 Session 而不是 JWT

前面埋的坑,这里填上。核心是 sessionVersion 字段实现的「全端强制下线」

// backend/middleware/auth.js
async function loadCurrentUser(ctx) {
  const userId = ctx.session && ctx.session.userId;
  if (!userId) return null;

  const user = await User.findByPk(userId);
  if (!user || user.status !== 'active') {
    ctx.session = null;
    return null;
  }

  // 核心:Session 里存的版本号 与 DB 中的当前版本号 必须一致
  if (Number(ctx.session.sessionVersion) !== Number(user.sessionVersion)) {
    ctx.session = null;     // 版本不匹配 → 这个会话已失效
    return null;
  }

  return user;
}

用户执行「改密码 / 解绑微信 / 主动登出所有设备」时,只需 sessionVersion++

await user.increment('sessionVersion');
// 此刻,该用户在所有设备上的所有会话,下一次请求立即失效

用 JWT 想实现同等效果,你得维护一个黑名单表,每次请求都查一遍——那你已经退化成 Session 了,还平白多了 Token 体积和签名开销。

选型原则:无状态不是银弹。当业务本质上需要状态(可撤销的登录态)时,硬上无状态方案只会让架构变形。

5.3 简历分享链接:拒绝 ID 遍历

简历包含姓名、电话、邮箱等强隐私信息。用 /resume/123 这种自增 ID,等于把全站用户的隐私挂在公网上任人爬取。

方案是加密随机 Slug

const crypto = require('crypto');

// 22 位 base64url,熵约 128 bit,暴力枚举不可行
function generateResumeSlug() {
  return crypto.randomBytes(16).toString('base64url');
}

再叠加分享时脱敏

function maskContact(value, type) {
  if (!value) return '';
  if (type === 'phone') {
    return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
  }
  if (type === 'email') {
    const [name, domain] = value.split('@');
    if (!domain) return value;
    const visible = name.slice(0, Math.min(2, name.length));
    return `${visible}${'*'.repeat(Math.max(1, name.length - 2))}@${domain}`;
  }
  return value;
}

外加一个搜索引擎收录开关(默认关闭),关闭时对分享页下发 X-Robots-Tag: noindex, nofollow。三道防线:不可枚举 + 内容脱敏 + 不被索引


六、SEO 工程化:Sitemap 分片与 hreflang 自动化

内容型产品的流量命脉在 SEO。我们有:核心页 + 7 个模板详情页 + 数百篇职场/面试文章,再 × 4 种语言,URL 数量轻松破千。

6.1 分片索引

单个 sitemap.xml 上限是 5 万条 URL / 50MB,虽然远没到,但分片的真正价值在于「增量更新」——文章更新了只需重新生成 sitemap-articles.xml,模板页纹丝不动,搜索引擎抓取效率更高。

<!-- public/sitemap.xml -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://beautyresume.com/sitemap-core.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-templates.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-articles.xml</loc></sitemap>
</sitemapindex>

6.2 hreflang 全交叉自动生成

多语言 SEO 最容易出错的地方:hreflang 必须双向全交叉——每个语言版本的页面都要列出所有语言版本(包括自己),还要有 x-default。手写必错。

// frontend/scripts/generate-sitemap.js
const SITE_ORIGIN = (process.env.SITE_ORIGIN || 'https://beautyresume.com').replace(/\/$/, '');
const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja'];
const DEFAULT_LOCALE = 'zh-CN';

function buildAlternates(pathWithoutLocale) {
  const links = LOCALES.map(
    (l) => `<xhtml:link rel="alternate" hreflang="${l}" href="${SITE_ORIGIN}/${l}${pathWithoutLocale}" />`
  );
  links.push(
    `<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_ORIGIN}/${DEFAULT_LOCALE}${pathWithoutLocale}" />`
  );
  return links.join('\n');
}

function buildUrlEntries(pathWithoutLocale, { changefreq = 'weekly', priority = 0.8 } = {}) {
  const alternates = buildAlternates(pathWithoutLocale);
  // 每个语言各生成一条 <url>,且都挂上完整的 alternates
  return LOCALES.map((locale) => `<url>
<loc>${SITE_ORIGIN}/${locale}${pathWithoutLocale}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
${alternates}
</url>`).join('\n');
}

配套一个 validate-sitemap.js 校验脚本,检查 URL 可达性、hreflang 对称性、重复 loc,并接进 CI

{
  "scripts": {
    "seo:validate": "yarn sitemap:generate && yarn sitemap:validate",
    "build": "node scripts/generate-sitemap.js && next build"
  }
}

Sitemap 生成塞进 build 流程,意味着它永远不会过期——这比「记得手动更新 sitemap」的人肉流程可靠一万倍。

6.3 Next.js metadata 与 canonical

// frontend/app/[locale]/layout.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale } = await params;
  return {
    metadataBase: new URL(SITE_ORIGIN),   // 所有相对 URL 自动补全为绝对 URL
    alternates: {
      canonical: localePath(locale, pathWithoutLocale),
      languages: buildLanguageAlternates(pathWithoutLocale),
    },
    openGraph: { /* ... */ },
  };
}

七、部署:Next.js standalone 让镜像瘦身 80%

7.1 standalone 产物

next.config.js 里一行配置:

module.exports = {
  output: 'standalone',
};

Next.js 会做依赖树静态分析,只把真正被引用到的 node_modules 文件拷进 .next/standalone。对比数据:

方案 体积
完整 node_modules + .next ~1.2 GB
standalone 产物 ~180 MB

打包脚本 scripts/standalone-pack.js 做的事:

.next/standalone/          ← 主体(含精简版 node_modules 和 server.js)
  + .next/static/          ← 必须手动拷贝!Next 不会自动放进去
  + public/                ← 同上,必须手动拷贝
  → archiver 打成 zip

踩坑警告.next/staticpublic 不会被自动包含进 standalone 目录。这是 Next.js 官方文档里一句轻描淡写的备注,但漏了就是「页面能打开但 CSS/JS 全 404」。第一次部署几乎人人中招。

7.2 PM2 集群 + 优雅停机

pm2 start bin/www -i max --name beautyresume-api

backend/bin/www 里处理优雅停机,避免发版时正在处理的请求被硬切断:

const server = app.listen(port);

function shutdown(signal) {
  console.log(`[${signal}] shutting down gracefully...`);
  server.close(async () => {
    await sequelize.close();     // 关连接池
    process.exit(0);
  });
  // 兜底:15 秒还没退干净就强杀,防止僵尸进程占端口
  setTimeout(() => process.exit(1), 15000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

7.3 Nginx 关键配置

# 静态资源:hash 文件名,强缓存一年
location /_next/static/ {
    proxy_pass http://127.0.0.1:3001;
    proxy_cache_valid 200 365d;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# API 反代到 Koa
location /api/ {
    proxy_pass http://127.0.0.1:7001;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# SSR 页面
location / {
    proxy_pass http://127.0.0.1:3001;
    proxy_set_header Host $host;
}

gzip on;
gzip_types text/plain text/css application/json application/javascript
           application/xml image/svg+xml font/ttf font/woff2;
gzip_min_length 1024;

配套提醒:只有在 Nginx 正确设置了 X-Forwarded-For 的前提下,后端才能开 TRUST_PROXY=1。否则攻击者可以伪造该头绕过 IP 限流——这两处配置必须成对出现,缺一不可。


八、一些踩坑与经验总结

8.1 React 19 下的 PDF 渲染坑

@react-pdf/renderer 内部有自己的 reconciler。在 React 19 的严格模式下,usePDFupdateInstance 可能被调用两次,产生两个 blob URL。解决方案就是前面提到的 currentUrlRef 令牌校验 + 在 useEffect cleanup 中 URL.revokeObjectURL() 回收,否则连续编辑十分钟能吃掉几百 MB 内存。

8.2 Tiptap 不要用 StarterKit

// ❌ 一把梭,体积翻倍
"@tiptap/starter-kit": "^3.20.4"

// ✅ 按需引入,简历只需要加粗和列表
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-list": "^3.20.4"

StarterKit 打包了 20+ 扩展(标题、代码块、引用、图片、表格……),简历场景一个都用不上。按需引入后编辑器 chunk 减小约 40%

更重要的是:功能少 = 用户不会做出奇怪的排版。约束本身就是产品设计。

8.3 Redux 细粒度订阅

简历数据是深层嵌套大对象,如果每个表单组件都 useSelector(state => state.resume),任何一个字段变更都会导致全表单重渲染

// ❌ 任何字段变化,所有订阅组件全部重渲染
const resume = useSelector((s) => s.resume);

// ✅ 只订阅自己关心的切片
const workList = useSelector((s) => s.resume.work.list, shallowEqual);

// ✅ 派生数据用 createSelector 记忆化
const selectVisibleSections = createSelector(
  [(s) => s.resume.sections, (s) => s.resume.sectionOrder],
  (sections, order) => order.filter((id) => sections[id]?.visible)
);

8.4 安全清单(血泪版)

做法
密码 bcrypt,cost ≥ 10,永不明文/MD5
会话 httpOnly + signed + sameSite=lax + 生产强制 secure
CSRF Origin / Referer / Sec-Fetch-Site 三重校验
限流 全局 IP 120/min,登录/验证码接口单独收紧
注入 全程 Sequelize 参数化,禁止字符串拼 SQL
XSS 富文本入库前 sanitize-html 白名单过滤
越权 每个资源操作校验 resource.userId === ctx.state.user.id
日志 敏感字段哈希脱敏(LOG_HASH_SECRET)后再落盘
密钥 启动时校验长度 ≥ 32,不合规直接拒绝启动
枚举 公开资源一律用加密随机 Slug

最后一条最容易被忽略:把「配置校验」放在启动阶段。让错误在部署时暴露,而不是在被攻击时暴露。


九、写在最后

回顾整个项目,最大的收获不是学会了某个 API,而是几个可迁移的工程判断

  1. 一致性优先于性能。 预览和导出共用一套代码,看起来是给自己找麻烦(性能压力全压在预览上),但它从架构层面消灭了「所见非所得」这一整类 bug。性能问题可以用工程手段(双缓冲、防抖)解决,一致性问题只能靠架构保证。

  2. 无状态不是银弹。 JWT 很潮,但当业务需要「可撤销的登录态」时,Session + 版本号才是正解。选型要看业务本质,不看技术热度。

  3. 把安全变成架构约束,而不是开发纪律。 前缀鉴权网关、启动时密钥校验、build 时 Sitemap 生成——让正确的事情自动发生,比写十页开发规范有用。

  4. 遇到三方库 bug,patch-package 优于 fork。 补丁进仓库,升级时自动提醒复查,维护成本几乎为零。

  5. 约束是好设计。 Tiptap 只开加粗和列表,用户反而做不出丑简历。


相关链接

如果这篇文章对你有帮助,欢迎点赞收藏。有任何技术问题欢迎在评论区交流,我会逐条回复。

转载请注明出处。文中代码均来自线上真实项目,可放心参考。

#Next.js#React 19#Koa2#PDF实时预览#全栈#简历编辑器#SEO#多语言#架构设计