业务背景

货代日常需管理 柜号、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 订单页「检索」 orderscontainersattachmentsdelivery_items
B 货柜大表 CRUD cargo_sheetcargo_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
// router/index.js
{ 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)。

需要浏览器能力(useStateuseEffectonClicklocalStorage)时,文件顶部加:

1
"use client";

对照

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(); // 让 Server 侧缓存失效,类似重新拉 layout

五、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);
// prisma.orders.findMany(...)
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>

常用:flexgridmin-h-screentext-smborder-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 点

  1. 路由靠文件夹,不是 router.js 配置表。
  2. 默认 Server Component;有交互就加 "use client"
  3. API 和页面在同一个 repofetch('/api/...') 相对路径即可。
  4. 没有 .vue 单文件,JSX 即模板;一个文件里 export default function 就是组件。
  5. Hooks 规则:只在函数组件顶层调用 useState / useEffect,不能放在 if 里。

十二、延伸阅读(官方)

读完本文后,打开 04-请求链路详解 看三条真实链路。

技术栈速查

一、TypeScript 速览

1.1 与 Java 的类型差异

Java TypeScript(本项目)
Long id id: stringbigint(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
// 类型别名(类似 Java record / DTO)
type OrderRow = {
id: string;
container_no: string;
customer?: string | null;
};

// 函数参数与返回
async function loadOrders(page: number): Promise<OrderRow[]> { ... }

// 泛型(api-response)
function success<T>(data: T) { ... }

// 类型导入(仅编译期,不会打进 bundle)
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 里是 bigintJSON.stringify 会报错。

解决: 所有 API 返回前走 serialize()src/lib/serialize.ts),把 bigint 转成 string

2.4 改表流程(本项目)

  1. prisma/schema.prisma
  2. 写或更新 prisma/sql/*.sql
  3. npm run db:ensure-parse(或对应脚本)
  4. npx prisma generate
  5. 代码里使用新型别

三、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,
})));
}
// parsed.data 类型安全

3.3 Mapper 层

Zod 校验通过后,mapper 把 DTO 转成 Prisma 的 CreateInput

1
2
3
4
5
6
7
8
9
// order-mapper.ts
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)   // admin | operator
canDelete(role) // admin | operator

六、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」:

  1. prisma/schema.prismacustomers model + SQL
  2. src/lib/customer-validators.ts
  3. src/lib/customer-mapper.ts
  4. src/lib/customer-list-query.ts
  5. src/lib/customer-columns.ts
  6. src/app/api/v1/customers/route.ts + [id]/route.ts
  7. src/app/customers/page.tsx(复制 orders 页改字段)
  8. Sidebar.tsx 加菜单
  9. 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
// lib/api-response.ts(示意)
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 + 连接池

系列导航

主题
本篇 架构与数据模型
下一篇 JWT 双 Token 与 RBAC
索引 专栏首页

← 返回专栏首页