1. 业务场景:为什么要在 Web 里接 Gmail?
在物流/货代业务里,运营同事每天会收到大量带 柜号 的邮件,附件往往是 Excel 或 CSV 派送明细。传统做法是人工打开邮箱、搜索柜号、下载附件、再粘贴到内部系统------慢、易漏、难追溯。
用 Next.js + Gmail API 可以把流程改成:
- 用户在订单页点「检索」
- 后端按柜号搜索已授权邮箱
- 自动下载 xlsx/csv 附件
- 交给解析模块入库(FBA、仓库、箱数等)
本文聚焦 第 2~3 步:OAuth 授权、搜索策略、附件下载。解析与入库在专栏下一篇展开。
2. 架构要点:两套 Cookie,千万别混
很多初学者把「用户登录系统」和「读取 Gmail」做成同一套 Token,结果要么权限过大,要么刷新逻辑互相污染。
| Cookie | 用途 | 签发方 |
|---|---|---|
app_access_token / app_refresh_token |
登录本系统 | /api/v1/auth/login |
gmail_access_token / gmail_refresh_token |
读 Gmail | Gmail OAuth 回调 |
能登录 ≠ 能搜邮件。 检索前必须单独走一遍 Gmail OAuth。
统一入口:
typescript
// gmail-tokens.ts
export async function resolveGmailTokens(request: Request) {
const access = getCookie(request, "gmail_access_token");
if (access) return { accessToken: access };
const refresh = getCookie(request, "gmail_refresh_token");
if (refresh) {
const refreshed = await refreshAccessToken(refresh);
return { ...refreshed, refreshed: true };
}
return null;
}
API 层拿到 null 时返回 401,并在 meta.needReconnect: true 里告诉前端:请跳转 /api/v1/gmail/auth 重新授权。
3. OAuth 流程(App Router)
3.1 环境变量
env
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxx
GOOGLE_REDIRECT_URI=http://localhost:3000/api/v1/gmail/callback
GMAIL_DEFAULT_SENDER=ops@example.com
Google Cloud Console 里:
- 启用 Gmail API
- OAuth 同意屏幕配置测试用户
- 重定向 URI 与
GOOGLE_REDIRECT_URI完全一致 - Scope 建议:
https://www.googleapis.com/auth/gmail.readonly(只读即可)
3.2 路由分工
| 步骤 | 路径 | 职责 |
|---|---|---|
| 发起授权 | GET /api/v1/gmail/auth |
302 到 Google 授权页 |
| 回调换 Token | GET /api/v1/gmail/callback |
code → access + refresh |
| 写 Cookie | gmail-tokens.ts |
HttpOnly、Secure(生产) |
| 前端提示 | GmailAuthNotifier.tsx |
读 ?gmail_connected=true |
回调核心逻辑:
typescript
// callback/route.ts(示意)
const { tokens } = await oauth2Client.getToken(code);
const response = NextResponse.redirect(new URL("/orders", request.url));
setGmailTokenCookies(response, tokens);
return response;
3.3 Token 刷新
Access Token 约 1 小时过期。resolveGmailTokens 在只有 refresh 时自动刷新,并在响应里 setGmailTokenCookies 写回。前端无需感知,除非 refresh 也失效------此时引导用户重新 OAuth。
4. 按柜号搜索:Fallback 策略
函数签名:
typescript
searchEmailsByContainer(
containerNo: string,
sender?: string,
accessToken: string,
refreshToken?: string
): Promise<EmailSearchHit[]>
两次查询 fallback:
查询 1:from:{GMAIL_DEFAULT_SENDER} {柜号}
↓ 无结果
查询 2:{柜号} (不限发件人)
为什么需要 fallback? 业务上常配置「默认发件人」,但实际邮件可能来自客户个人邮箱。只查 from: 会漏信;第二次放宽发件人,仅用柜号关键词命中。
Gmail 搜索语法示例:
from:ops@example.com ABCD1234567
ABCD1234567
每条命中邮件拉 metadata(Subject / From / Date),并标记 hasExcelAttachment(含 xlsx 或 csv)。
5. 多封命中:如何选「最佳邮件」
同一柜号可能有多封往来邮件。全自动流程需要 pickBestEmailForParse():
- 优先 带
isParseable附件的邮件(xlsx/xls/csv) - 再比较 snippet 长度、附件数量、附件大小等打分
- 线 A(订单检索)全自动用得分最高的一封
- 线 B(弹框检索)可让用户手动点某一封看详情
typescript
function scoreEmail(email: EmailSearchHit): number {
let score = 0;
if (email.hasParseableAttachment) score += 100;
score += Math.min(email.snippet.length, 200) / 10;
score += email.attachmentCount;
return score;
}
6. 附件下载:Buffer 不进数据库事务
6.1 可解析附件判定
typescript
export function isParseableAttachment(mime: string, filename: string) {
return isExcelMimeType(mime) || isCsvFile(mime, filename);
}
function isExcelMimeType(mime: string) {
return /spreadsheet|excel|xlsx|xls/i.test(mime);
}
function isCsvFile(mime: string, filename: string) {
return mime.includes("csv") || filename.toLowerCase().endsWith(".csv");
}
6.2 下载接口
typescript
async function downloadAttachmentBuffer(
messageId: string,
attachmentId: string,
accessToken: string
): Promise<Buffer> {
const res = await gmail.users.messages.attachments.get({
userId: "me",
messageId,
id: attachmentId,
});
return Buffer.from(res.data.data!, "base64");
}
6.3 与 Prisma 事务的边界
反模式: 在 Prisma $transaction 里调 Gmail 下载 + Excel 解析。网络/解析一旦失败,PostgreSQL 可能进入 25P02(事务 aborted),后续 SQL 全部挂掉。
推荐模式:
① 事务外:search → pickBest → download → parse
② 短事务:写 delivery_items / parse_logs / attachments
Vercel 上检索 API 建议 export const maxDuration = 120(长任务)。
7. 完整链路(订单页「检索」)
orders/page.tsx 点「检索」
→ POST /api/v1/orders/[id]/search
→ parseOrderFromGmail() // order-parse-service.ts
→ generateBatchNo()
→ insert containers (parsing)
→ searchEmailsByContainer()
→ pickBestEmailForParse()
→ getEmailDetail()
→ downloadAttachmentBuffer() // 事务外
→ parseDeliveryFileBuffer() // 下一篇详解
→ $transaction 写库
→ parse_logs save_database
batch_no 通常取 String(containers.id),同柜多次检索产生多批次,便于审计与回滚。
8. 常见踩坑清单
| 现象 | 原因 | 处理 |
|---|---|---|
| 401 needReconnect | Gmail Token 过期 | 重新走 /gmail/auth |
| 搜不到邮件 | 只查了默认发件人 | 确认 fallback 第二次查询 |
| 附件列表为空 | 邮件只有链接无附件 | 前端提示「无 xlsx/csv」 |
| 生产 OAuth 失败 | redirect_uri 不一致 | Console 与 env 逐字对比 |
| 事务里下载超时 | IO 在 transaction 内 | IO 移出事务 |
关于作者
devcfg 以已知,溯本源,探未知 · Next.js · Excel 解析 · 工程实战
专注全栈业务自动化,同系列文章收录于本人 CSDN 专栏。
9. 小结
Gmail 集成的关键是三件事:
- 登录与 Gmail 授权分离 (两套 Cookie +
resolveGmailTokens) - 搜索要有 fallback(默认发件人 + 纯柜号)
- 下载解析不进长事务(先 IO,再短事务写库)
下一篇我们将讲 Excel/CSV 派送表动态解析:表头不在第 1 行、列名 alias 映射、仓库汇总。