📚 离职知识库

05 · 技术资料:前端、上传与解析流水线


#1. 版本与脚手架

npx create-next-app@latest baton --typescript --tailwind --app --no-src-dir --import-alias "@/*"

建完把 package.json 的依赖版本对齐 08-可复用资产清单 §2(Next 16.2.10 / React 19.2.4 / Tailwind ^4 / shadcn ^4.13.1)。

npx shadcn@latest init          # Tailwind config 路径问项时【留空】(v4 用 CSS @theme,不再有 tailwind.config.js)
npx shadcn@latest add button card table sheet dialog badge switch progress tabs command sonner \
                       input label select separator skeleton avatar tooltip scroll-area checkbox

next.config.ts

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // 这三个包体积大 / 有动态 require,必须让 Next 走运行时 require 而不是打包
  serverExternalPackages: ['unpdf', 'mammoth', 'exceljs'],
}

export default nextConfig

#2. Next.js 16 相对 15 的破坏性变更(会咬到本项目的)

变更 对 Baton 的影响 怎么写
middleware.tsproxy.ts 密码门用 proxy.ts,导出 export async function proxy(req) 直接抄 english-daily 的(08 §1)
proxy.ts 默认跑 Node runtime(不再是 Edge) process.env 是运行时读取,行为更符合直觉 无需特殊处理
params / searchParams / cookies() / headers() 全部异步 每个动态路由都要改 const { id } = await params
revalidateTag() 必须传第二参数 若用到缓存标签 revalidateTag('files', 'max')
cacheComponents / "use cache" Baton 的数据要实时(进度条、状态),不加 "use cache" 即可 保持默认
Server Action 请求体仍 4.5MB 上限 上传 ⛔ 不能走 Server Action 见 §3
Node 最低 20.9+ 本机 v22.22.2 ✅
Turbopack 成为默认构建器 有自定义 webpack 配置才需注意 本项目无
next/imageimages.qualities 默认收窄为 [75] 本项目基本不用 next/image

#3. 文件上传:Vercel Blob 客户端直传

#3.1 为什么必须直传

Vercel Function(API Route 和 Server Action 一样)请求体上限 4.5MB,超了返回 413 FUNCTION_PAYLOAD_TOO_LARGE。文档动辄十几 MB。 所以让浏览器绕过自己的函数,直接把字节传到 Blob;服务端只签发一个短时效令牌。

npm i @vercel/blob

#3.2 服务端令牌路由 app/api/blob/upload/route.ts

// 客户端直传的看门人:不接收文件本体,只签令牌 + 接收完成回调
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
import { NextResponse } from 'next/server'

export const runtime = 'nodejs'

export async function POST(request: Request): Promise<NextResponse> {
  const body = (await request.json()) as HandleUploadBody
  try {
    const json = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async () => {
        // 🔴 必须鉴权。不做这一步 = 把 Blob 库对全网开放。
        // Baton 走的是密码门 Cookie,这里校验会话 Cookie 是否有效。
        // (proxy.ts 已经拦了未登录请求,但这里要再确认一次,不能只靠中间件。)
        return {
          allowedContentTypes: [
            'application/pdf',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'text/plain', 'text/markdown',
          ],
          addRandomSuffix: true,
          maximumSizeInBytes: 20 * 1024 * 1024,   // 20MB,对应 AC-2.1.3
        }
      },
      onUploadCompleted: async ({ blob }) => {
        // ⚠️ 本地开发收不到这个回调(Vercel Blob 回调不到 localhost)。
        // 所以【建档不要只依赖这个回调】—— 前端拿到 blob.url 后再显式调一次建档接口。
        void blob
      },
    })
    return NextResponse.json(json)
  } catch (e) {
    return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 })
  }
}

#3.3 前端调用要点

import { upload } from '@vercel/blob/client'

const blob = await upload(`baton/${employeeId}/${Date.now()}-${file.name}`, file, {
  access: 'public',                     // 私有资料理论上要 private;若 private 读取要签名 URL,今晚可先 public + 路径含随机后缀
  handleUploadUrl: '/api/blob/upload',
  multipart: file.size > 10 * 1024 * 1024,
  onUploadProgress: ({ percentage }) => setProgress(percentage),
})
// 拿到 blob.url 后显式调建档接口(不要只靠 onUploadCompleted,本地收不到)
await fetch('/api/files', { method: 'POST', body: JSON.stringify({ url: blob.url, ... }) })

#3.4 ⚠️ 创建 Blob store 的坑

若 Vercel 项目还没有 Blob store,需要 vercel blob create-store这条命令历史上覆写过 .env.local,导致四个孤本 sensitive 值当场丢失。

cp .env.local .env.local.bak     # ⛔ 不做这步不许跑那条命令

拿不到 BLOB_READ_WRITE_TOKEN → 走 04 §1 决策点 D2 的降级方案。


#4. 文档解析选型(serverless 友好)

格式 为什么 出处怎么保
PDF unpdf unjs 出品,内置去掉 canvas 的 pdf.js,零原生依赖,官方测过 Vercel Functions。⛔ 不要用 pdf-parse(依赖原生 canvas,serverless 会崩)也不要裸用 pdfjs-dist extractText(pdf, { mergePages: false }) 返回按页数组,下标+1 = 页码
docx mammoth 成熟稳定 .docx 没有物理页码(分页是渲染时算的)→ 用段落序号 + 标题层级 heading_path
xlsx exceljs ⚠️ ⛔ 不要 npm i xlsx —— npm 上的 SheetJS 停更多年,最新只有 0.18.5,带已知原型污染/DoS 漏洞(官方新版只发在自家 CDN)。exceljs 正常维护、内存占用更低 worksheet.eachRow((row, rowNumber) => ...)Sheet名!行号
txt/md 原生 TextDecoder 按标题分节
npm i unpdf mammoth exceljs

#4.1 PDF 解析 + 扫描件检测(对应 AC-2.2.5)

// lib/parse/pdf.ts —— 逐页提取,同时检测"疑似扫描件"
import { extractText, getDocumentProxy } from 'unpdf'

export async function parsePdf(buffer: ArrayBuffer) {
  const pdf = await getDocumentProxy(new Uint8Array(buffer))
  const { totalPages, text } = await extractText(pdf, { mergePages: false })
  const pages = text.map((t, i) => ({ pageNumber: i + 1, text: t }))

  // 扫描件(图片型 PDF)没有文字层,提取出来几乎是空的。
  // 判据:超过 60% 的页面非空白字符数 < 20 → 判定扫描件,不做 OCR,直接报错态。
  const empty = pages.filter(p => p.text.replace(/\s/g, '').length < 20).length
  const likelyScanned = totalPages > 0 && empty / totalPages > 0.6

  return { totalPages, pages, likelyScanned }
}

likelyScanned === trueparse_status = 'failed'parse_error = '疑似扫描件/图片型 PDF,暂不支持 OCR,请上传可选中文字的版本',UI 用警告态展示。 ⛔ 不许静默成功(那样用户以为传成功了,其实库里是空的)。

#4.2 xlsx 要点

row.values 的下标 0 是空占位,从 1 开始才是第一列 → 记得 .slice(1)。 每个 chunk 必须带上表头行,否则脱离表头的数字没有语义。


#5. 长任务架构

#5.1 先纠正一个过时说法

Vercel Hobby 的函数超时现在是 300 秒(5 分钟),不是老文章里说的 10s/60s —— Fluid Compute 已对新项目默认开启,Hobby 内存上限 2GB。 (waitUntil() / after() 里的后台代码仍然算在同一次调用的 maxDuration 预算内,不是绕过超时的手段。)

#5.2 选定方案:前端轮询驱动分步处理

即便 300 秒理论够跑完一份 50 页 PDF,也不要一把梭。理由:任何一步失败就前功尽弃、没有断点续传、进度条卡住不知道卡在哪。

架构:前端拿到 fileId 后循环调用 POST /api/files/[id]/step,每次只做一小块,返回当前状态和进度,直到 done / failed

pending  → parsing   (下载 + 解析 + 切片 + 落 chunk,embedding_status 全 pending)
parsing  → chunking  (落库完成)
chunking → embedding
embedding→ embedding (每次取 N=40 条 pending chunk 做 embedding,取不到就转 done)
         → done
任何一步异常 → failed(保留 parse_error)

断点续传是天然的:每次只取 embedding_status = 'pending' 的 chunk,已完成的不会重算。用户关掉浏览器再回来,接着轮询即可。

// app/api/files/[id]/step/route.ts 的骨架
export const runtime = 'nodejs'
export const preferredRegion = 'hnd1'   // 东京,就近连 Leo-hub
export const maxDuration = 300

export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params          // Next 16:必须 await
  // ...按当前 parse_status 分支处理一小步,返回 { status, progress }
}

前端 hook 每次响应后 setTimeout(step, 300) 继续,直到 done / failed

⚠️ 兜底:如果用户传完就关页面,任务会停在原地。今晚不做 Cron 兜底(Hobby 的 Cron 每天只能跑 1 次),改成:文件列表页对非终态文件显示「继续处理」按钮,点一下续跑。这也是一条可测的 UI 行为。


#6. embedding 批量调用

// lib/embed.ts —— 批量 + 指数退避 + 全抖动
const BASE = process.env.YUNWU_API_BASE!      // https://yunwu.ai/v1
const KEY  = process.env.YUNWU_API_KEY!
const MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small'

export async function embedBatch(texts: string[]): Promise<number[][]> {
  if (!texts.length) return []
  let lastErr: unknown
  for (let attempt = 0; attempt <= 5; attempt++) {
    try {
      const res = await fetch(`${BASE}/embeddings`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ model: MODEL, input: texts }),
      })
      if (res.status === 429 || res.status >= 500) throw new Error(`embedding ${res.status}`)
      if (!res.ok) {
        const t = await res.text()
        throw Object.assign(new Error(`embedding ${res.status}: ${t}`), { retryable: false })
      }
      const data = await res.json()
      return (data.data as { embedding: number[] }[]).map(d => d.embedding)
    } catch (e) {
      lastErr = e
      if ((e as { retryable?: boolean }).retryable === false || attempt === 5) break
      // 全抖动退避:random(0, min(500 * 2^n, 15000))
      await new Promise(r => setTimeout(r, Math.random() * Math.min(500 * 2 ** attempt, 15000)))
    }
  }
  throw lastErr
}
  • 单批 30–50 条,单条 chunk ≤ 800 token
  • 并发用 p-limit 控在 3–5
  • 失败的 chunk 留 embedding_status='failed' + embedding_retry_count,超 5 次就放弃这条继续跑,⛔ 不要卡死整份文件(AC-2.3.2)

测试怎么写:集成测试里不打真实 embedding API,用固定 fixture 向量(这是 02-TDD规程 §3 明确允许 mock 的少数几项之一)。但解析和入库必须真跑


#7. 界面要点(浅色干净风)

#7.1 硬性可测项(写成 E2E 断言)

  • body 背景 #f6f7f9 系;卡片 #ffffff;二者不同(AC-7.1.2)
  • 1280px 宽无横向滚动(AC-7.1.3)
  • 五个页面在空数据时都有空态文案,⛔ 不许白屏(AC-7.2.6)
  • 所有 API 错误走 Sonner toast,⛔ 不许静默(AC-7.3.1)
  • 列表加载中显示 Skeleton(AC-7.3.2)

#7.2 视觉规则

  • 单一强调色 indigo #6366f1,其余全灰阶;状态色只用 shadcn 的 destructive
  • 圆角统一 0.625rem;表格行高 ≥ 44px;留白宁多勿少
  • 中文字体栈必须含 "PingFang SC"
  • 交接页是主场,允许一点克制动效(勾选时飞入右栏、确认成功微动画)
  • ⛔ 深色科技风 / 霓虹 / 玻璃拟态 / 大面积渐变

#7.3 种子数据(P1 就要有)

三个员工:王销售(华东区销售,家居建材,负责宏远建材)、李销售(接手王销售客户)、赵采购(与销售客户无关,用来验证隔离)。 虚构文档 3–5 份:报价单、合同、客户资料、供应商清单。 记忆条目 12–20 条,覆盖五个分类。 ⚠️ 全部虚构,⛔ 不要用任何真实公司/人名。


#8. 坑清单

  1. 413 FUNCTION_PAYLOAD_TOO_LARGE → 走 Blob 客户端直传,⛔ 不要用 Server Action 收文件。
  2. 本地 onUploadCompleted 不触发 → Blob 回调不到 localhost。建档改为前端显式调接口。
  3. PDF 在 Vercel 上报 canvas 错误 → 换 unpdf
  4. npm i xlsx 装到带漏洞的 0.18.5 → 换 exceljs
  5. 函数体积超 250MB / 构建报错serverExternalPackages 里加 unpdf mammoth exceljs
  6. params 同步解构报错 → Next 16 全部要 await
  7. 密码门本地失效.env.local 里必须补 HUB_SITE_PASSWORD / HUB_AUTH_SECRET,否则 proxy 拦一切、/api/login 返 503,E2E 全跑不了。
  8. NEXT_PUBLIC_ 前缀误用 → 服务端密钥一律不加这个前缀,否则被打进浏览器 bundle。
  9. Vercel Framework Preset 不自动改 → 历史坑:Vite 迁 Next 后 preset 没改,线上静默 404。新建项目一般没这问题,但部署后必须真的打开线上 URL 确认,别只看 "Ready"。
  10. shadcn init 问 Tailwind config 路径 → Tailwind v4 下留空
  11. Vercel 环境变量三套环境 → Production / Preview / Development 都要配,漏一套预览就崩。
  12. sensitive 变量写进去读不回明文 → 自己生成的密钥先在本地 .env.local 留一份再写上去。
  13. 中文文本噪声 → 解析出来常带控制字符、多余空白、#REF! 之类。切片前过一遍 normalizeText(),空 chunk 直接丢。
  14. Playwright 跑不起来 → 记得 npx playwright install chromium

来源:沉淀/03-项目方案与交接/接棒-通宵施工包-20260731/05-技术资料-前端与流水线.md(整理于 2026-08-18)