业务背景
货代日常需管理 柜号、MBL、ETA、LFD、DO、司机 等字段,并从邮件附件中的 Excel/CSV 派送表 提取 FBA ID、仓库代码、箱数等明细。人工复制粘贴易错、难追溯。
本系统(Gmail 自动处理系统)目标:在线管订单 + 一键 Gmail 检索 + 自动解析入库 + 按批次审计。
系统架构总览
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| flowchart TB subgraph UI["浏览器 React"] SHEET[货柜订单大表] ORD[订单列表] RES[解析结果 / 日志 / 汇总] end
subgraph Next["Next.js 14"] MW[middleware JWT 守卫] API["Route Handlers API 层"] SVC[Service 层 lib 模块] end
subgraph Ext["外部"] GM[Gmail API] XLS[Excel/CSV 附件] end
subgraph DB["PostgreSQL"] T1[(cargo_sheet)] T2[(orders)] T3[(containers)] T4[(delivery_items)] T5[(parse_logs)] end
UI --> MW --> API --> SVC SVC --> GM --> XLS SVC --> DB
|
两条业务线
1 2 3
| flowchart LR A[线 A:订单检索] --> B[orders] --> C[Gmail 搜柜号] --> D[containers 批次入库] E[线 B:货柜大表] --> F[cargo_sheet 23 字段] --> G[history 历史快照]
|
| 线 |
入口 |
核心表 |
| A |
订单页「检索」 |
orders → containers → attachments → delivery_items |
| B |
货柜大表 CRUD |
cargo_sheet → cargo_sheet_history |
分层职责
| 层 |
路径示例 |
职责 |
| Edge |
middleware.ts |
Cookie 存在性检查(不能用 Prisma) |
| API |
app/api/v1/orders/route.ts |
Zod 校验、requireUser、HTTP 响应 |
| Service |
lib/order-parse-service.ts |
Gmail + 解析 + 事务编排 |
| ORM |
prisma/schema.prisma |
模型与索引 |
| DB |
PostgreSQL |
持久化 |
Next.js 与 Vue/Java 对照(精要)
一、整体架构对照
| 概念 |
Vue + Java 常见做法 |
本项目 (Next.js) |
| 前端框架 |
Vue 3 + Vue Router |
React 18 + App Router(文件即路由) |
| 后端 |
Spring Boot 独立进程 :8080 |
Next.js API Routes,与前端 同进程 :3000 |
| ORM |
MyBatis / JPA |
Prisma |
| 校验 |
@Valid + Hibernate Validator |
Zod(*-validators.ts) |
| 鉴权 |
Spring Security Filter |
middleware.ts + JWT Cookie |
| 部署 |
前后端分开 |
一个 Next 应用(可 Vercel) |
关键差异: 没有单独的 axios baseURL 指向 Java 后端;页面里直接 fetch("/api/v1/orders"),Next 在服务端执行 route.ts。
二、App Router:文件系统路由
Vue Router 写法
1 2
| { path: '/orders', component: () => import('@/views/Orders.vue') }
|
Next.js App Router 写法
1 2 3
| src/app/orders/page.tsx → GET /orders 页面 src/app/login/page.tsx → GET /login src/app/api/v1/orders/route.ts → GET/POST /api/v1/orders API
|
| 文件 |
URL |
作用 |
app/layout.tsx |
所有页面外层 |
类似 App.vue 的 <router-view> 外壳 |
app/page.tsx |
/ |
首页 |
app/orders/page.tsx |
/orders |
订单页 |
app/api/v1/orders/route.ts |
/api/v1/orders |
REST API |
动态路由:
1 2
| app/api/v1/orders/[id]/route.ts → /api/v1/orders/123 app/cargo-sheet/[containerNo]/page.tsx → /cargo-sheet/ABCD1234567
|
在 API 里取参数:
1 2 3
| export async function GET(_req: Request, { params }: { params: { id: string } }) { const orderId = Number(params.id); }
|
类比 Spring:@PathVariable("id") Long id。
三、Server Component vs Client Component
Next.js 14 默认:所有组件都是 Server Component(在 Node 服务端渲染 HTML)。
需要浏览器能力(useState、useEffect、onClick、localStorage)时,文件顶部加:
对照
|
Vue SFC |
Next.js |
| 默认运行位置 |
浏览器 |
服务端(无 "use client") |
| 交互 / 状态 |
直接写 |
必须 "use client" |
| 数据请求 |
onMounted + axios |
Server 组件可 async 直接查库;Client 组件用 fetch |
本项目惯例
| 类型 |
示例 |
说明 |
| Server |
app/layout.tsx |
静态外壳,无交互 |
| Client |
app/orders/page.tsx |
整页表格交互,顶部 "use client" |
| Client |
LoginForm.tsx |
表单、toast |
| Server |
app/api/v1/**/route.ts |
纯服务端,永远不是 Client |
为什么订单页整页是 Client?
表格编辑、排序、列拖拽、localStorage 列偏好都需要浏览器 API;本项目选择「页面级 Client + fetch API」,而不是 Server 组件 SSR 数据。
四、React 语法对照 Vue(5 分钟上手)
4.1 组件与模板
Vue:
1 2 3 4 5 6 7
| <template> <button @click="count++">{{ count }}</button> </template> <script setup> import { ref } from 'vue' const count = ref(0) </script>
|
React(本项目风格):
1 2 3 4 5 6 7 8 9
| "use client"; import { useState } from "react";
export default function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}>{count}</button> ); }
|
| Vue |
React |
ref(0) |
useState(0) → [count, setCount] |
computed(() => ...) |
useMemo(() => ..., [deps]) |
watch(source, fn) |
useEffect(() => fn, [deps]) |
@click |
onClick |
:class="{ active: x }" |
className={x ? "active" : ""} |
v-if |
{condition && <div/>} |
v-for="item in list" |
{list.map(item => <div key={item.id}/>)} |
4.2 生命周期
| Vue |
React |
onMounted |
useEffect(() => { ... }, []) |
onUnmounted |
useEffect(() => { return () => cleanup }, []) |
watch(() => route.params.id) |
useEffect(() => { ... }, [id]) |
本项目订单页加载数据:
1 2 3 4
| useEffect(() => { void loadOrders(); void loadUser(); }, [loadOrders]);
|
4.3 路由跳转
| Vue Router |
Next.js |
router.push('/orders') |
const router = useRouter(); router.push('/orders') |
route.query.redirect |
useSearchParams().get('redirect') |
见 LoginForm.tsx:
1 2 3 4
| const router = useRouter(); const searchParams = useSearchParams(); router.push(searchParams.get("redirect") || "/cargo-sheet"); router.refresh();
|
五、API Route = Spring Controller
Spring
1 2 3 4 5 6 7 8 9
| @RestController @RequestMapping("/api/v1/orders") public class OrderController { @GetMapping public ResponseEntity<?> list(@RequestParam int page) { ... }
@PostMapping public ResponseEntity<?> create(@RequestBody OrderDto dto) { ... } }
|
Next.js Route Handler
文件:src/app/api/v1/orders/route.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export async function GET(request: Request) { const user = await requireUser(request); if (!user) return error("未登录", 401); return success(serialize(rows), pagination); }
export async function POST(request: Request) { const body = await request.json(); const parsed = orderCreateSchema.safeParse(body); if (!parsed.success) return error("Validation failed", 400, ...); const created = await prisma.orders.create({ data: buildOrderCreateInput(...) }); return success(serialize(created)); }
|
约定:
- 导出名必须是 HTTP 方法:
GET / POST / PUT / DELETE
- 返回
NextResponse.json(...),本项目封装为 success() / error()
六、Middleware = Filter / 路由守卫
文件:src/middleware.ts
| Spring Security |
Next middleware |
OncePerRequestFilter |
export function middleware(request) |
白名单 /login |
publicPaths 数组 |
| 无 Token → 401 |
API 返回 JSON 401 |
| 无 Token → 重定向 |
页面 NextResponse.redirect('/login') |
Edge Runtime 限制: 不能 import { prisma },不能 Node fs。只做 Cookie 存在性检查;细粒度权限在 API 里 requireUser + canWrite。
七、目录别名 @/
tsconfig.json:
1
| "paths": { "@/*": ["./src/*"] }
|
@/lib/auth = src/lib/auth,类似 Java 里统一包名前缀。
八、样式:Tailwind CSS
本项目 不用 Vue 的 <style scoped>,改用 utility class:
1 2 3
| <button className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"> 保存 </button>
|
常用:flex、grid、min-h-screen、text-sm、border-slate-200。
在浏览器 DevTools 里对着订单页看 class 即可快速熟悉。
九、本项目前端数据流范式
几乎所有业务页重复同一模式:
1 2 3 4 5 6 7
| 1. useEffect 加载列表 → GET /api/v1/xxx?page=1&pageSize=20 2. useState 存 rows、loading、pagination、sort 3. 双击行 → 行内编辑 → PUT /api/v1/xxx/[id] 4. 底部新行 → POST /api/v1/xxx 5. 勾选删除 → DELETE /api/v1/xxx/batch 6. toast.success / toast.error(sonner) 7. 列宽/顺序/可见性 → custom hook + localStorage
|
没有 Pinia / Vuex:状态都在页面组件 useState 里;用户信息每次 GET /api/v1/auth/me。
十、构建与运行
| 命令 |
类比 Java |
npm run dev |
spring-boot:run,热更新 |
npm run build |
mvn package,生产构建 |
npm run start |
java -jar,跑 build 产物 |
npx prisma generate |
根据 schema 生成 Client(类似 MyBatis generator) |
十一、和 Vue 比,你最该记住的 5 点
- 路由靠文件夹,不是
router.js 配置表。
- 默认 Server Component;有交互就加
"use client"。
- API 和页面在同一个 repo,
fetch('/api/...') 相对路径即可。
- 没有
.vue 单文件,JSX 即模板;一个文件里 export default function 就是组件。
- Hooks 规则:只在函数组件顶层调用
useState / useEffect,不能放在 if 里。
十二、延伸阅读(官方)
读完本文后,打开 04-请求链路详解 看三条真实链路。
技术栈速查
一、TypeScript 速览
1.1 与 Java 的类型差异
| Java |
TypeScript(本项目) |
Long id |
id: string 或 bigint(API 层常转 string) |
List<Order> |
OrderRow[] |
Optional<String> |
string | null | undefined,常用 string? 在 type 里 |
@Nullable |
field?: string 可选属性 |
| 接口 DTO |
type OrderRow = { id: string; container_no: string; ... } |
1.2 常见写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| type OrderRow = { id: string; container_no: string; customer?: string | null; };
async function loadOrders(page: number): Promise<OrderRow[]> { ... }
function success<T>(data: T) { ... }
import type { AuthUser } from "@/lib/auth"; import type { Prisma } from "@prisma/client";
|
1.3 async/await
与 Java 几乎相同。API route 和 Service 层大量:
1 2 3 4
| export async function POST(request: Request) { const body = await request.json(); const user = await prisma.users.findFirst({ where: { email } }); }
|
1.4 解构与展开
1 2
| const { email, password } = parsed.data; return NextResponse.json({ ...user, role: user.role });
|
二、Prisma ORM
2.1 角色对照
| Java |
Prisma |
@Entity + @Table |
model orders { ... @@map("orders") } |
@Id @GeneratedValue |
@id @default(autoincrement()) |
@ManyToOne |
orders orders @relation(...) |
| Repository 接口 |
直接 prisma.orders.findMany() |
@Query 手写 SQL |
prisma.$queryRaw(本项目少用) |
Schema 文件:prisma/schema.prisma
客户端单例:src/lib/prisma.ts
2.2 常用 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| await prisma.orders.findFirst({ where: { id: orderId } });
await prisma.orders.findMany({ where: { container_no: { contains: keyword, mode: "insensitive" } }, orderBy: { created_at: "desc" }, skip: (page - 1) * pageSize, take: pageSize, });
await prisma.orders.count({ where });
await prisma.orders.create({ data: { container_no: "...", created_by: userId } });
await prisma.orders.update({ where: { id }, data: { customer: "..." } });
await prisma.orders.delete({ where: { id } });
await prisma.$transaction(async (tx) => { await tx.containers.create({ ... }); await tx.parse_logs.create({ ... }); });
|
2.3 BigInt 问题
PostgreSQL BIGINT 在 JS 里是 bigint,JSON.stringify 会报错。
解决: 所有 API 返回前走 serialize()(src/lib/serialize.ts),把 bigint 转成 string。
2.4 改表流程(本项目)
- 改
prisma/schema.prisma
- 写或更新
prisma/sql/*.sql
npm run db:ensure-parse(或对应脚本)
npx prisma generate
- 代码里使用新型别
三、Zod 校验
3.1 对照 Bean Validation
| Java |
Zod |
@NotBlank String email |
z.string().min(1) |
@Email |
z.string().email() |
@Pattern |
z.string().regex(...) |
@Valid + BindingResult |
schema.safeParse(body) |
3.2 本项目模式
文件:src/lib/order-validators.ts
1 2 3 4 5 6 7 8 9
| import { z } from "zod";
export const orderCreateSchema = z.object({ container_no: z.string().min(1, "柜号不能为空"), customer: z.string().optional().nullable(), order_date: z.string().optional().nullable(), });
export type OrderCreateInput = z.infer<typeof orderCreateSchema>;
|
API 中使用:
1 2 3 4 5 6 7 8
| const parsed = orderCreateSchema.safeParse(body); if (!parsed.success) { return error("Validation failed", 400, parsed.error.issues.map(issue => ({ field: issue.path.join("."), message: issue.message, }))); }
|
3.3 Mapper 层
Zod 校验通过后,mapper 把 DTO 转成 Prisma 的 CreateInput:
1 2 3 4 5 6 7 8 9
| export function buildOrderCreateInput(data: OrderCreateInput, userId: bigint) { return { container_no: data.container_no.trim(), customer: data.customer ?? null, created_by: userId, updated_by: userId, }; }
|
三板斧: *-validators.ts + *-mapper.ts + *-list-query.ts
四、统一 API 响应
文件:src/lib/api-response.ts
1 2 3 4 5 6 7
| return success(data); return success(list, { page, pageSize, total, totalPages });
return error("未登录", 401); return error("Validation failed", 400, [{ field: "email", message: "..." }]);
|
前端统一判断:
1 2 3 4 5 6 7
| const res = await fetch("/api/v1/orders"); const json = await res.json(); if (!res.ok) { toast.error(json.message ?? "请求失败"); return; } const rows = json.data;
|
五、JWT 与 Cookie(jose 库)
文件:src/lib/auth.ts
| 环境变量 |
默认 |
说明 |
JWT_SECRET |
必填 ≥32 字符 |
签名密钥 |
JWT_ACCESS_EXPIRES |
15m |
Access Token |
JWT_REFRESH_EXPIRES |
7d |
Refresh Token |
Cookie 名:
app_access_token
app_refresh_token
角色权限:
1 2
| canWrite(role) canDelete(role)
|
六、Gmail 相关环境变量
1 2 3 4
| GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/api/v1/gmail/callback GMAIL_DEFAULT_SENDER=wenyang@ggtransport.in
|
Token 存 Cookie(gmail-tokens.ts),与登录 JWT 分离。
七、Excel / CSV 解析
| 库 |
用途 |
exceljs |
读 .xlsx |
| 手工解析 |
.csv → 二维数组 |
入口:parseDeliveryFileBuffer(buffer, filename, containerNo)
列映射:FIELD_ALIASES(中英文表头 → 标准字段)
八、UI 与工具库
| 库 |
用途 |
tailwindcss |
样式 |
lucide-react |
图标(类似 iconify) |
sonner |
Toast 通知 |
date-fns |
日期格式化 |
"dnd-kit 列拖拽" |
货柜订单大表 列拖拽排序 |
bcryptjs |
密码 hash |
googleapis |
Gmail API |
九、ESLint 与路径
- ESLint:
eslint-config-next
- 绝对导入:
@/lib/...、@/components/...
十、复制新模块 checklist
假设新增「客户管理 /customers」:
prisma/schema.prisma 加 customers model + SQL
src/lib/customer-validators.ts
src/lib/customer-mapper.ts
src/lib/customer-list-query.ts
src/lib/customer-columns.ts
src/app/api/v1/customers/route.ts + [id]/route.ts
src/app/customers/page.tsx(复制 orders 页改字段)
Sidebar.tsx 加菜单
middleware.ts 通常 不用改(已保护非公开路径)
下一步:04-请求链路详解
请求链路概览
核心数据模型(节选)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| model orders { id BigInt @id @default(autoincrement()) container_no String @unique @db.VarChar(20) customer String? @db.VarChar(100) }
model containers { id BigInt @id @default(autoincrement()) container_no String @db.VarChar(20) batch_no String @db.VarChar(32) parse_status parse_status @default(pending) is_history Boolean @default(false) }
model delivery_items { id BigInt @id @default(autoincrement()) container_id BigInt fba_id String? @db.VarChar(64) warehouse_code String? @db.VarChar(32) carton_count Int? }
|
批次号设计:每次解析新建 containers 行,batch_no = String(containers.id),便于按次追溯。
统一 API 响应
1 2 3 4 5 6 7
| return NextResponse.json({ code: 0, message: "ok", data: serialize(rows), pagination: { page, pageSize, total }, });
|
技术选型小结
| 组件 |
选型 |
原因 |
| 框架 |
Next.js 14 App Router |
页面 + API 同仓,部署简单 |
| ORM |
Prisma 5 |
类型安全、迁移脚本可控 |
| 校验 |
Zod |
API 入参与服务边界 |
| Excel |
ExcelJS |
xlsx 读写 + 自研 CSV 分支 |
| 部署 |
Vercel + Neon |
Serverless + 连接池 |
系列导航
← 返回专栏首页