开发者对接 Shopee 平台 API 接口的血与泪教训
摘要: 本文详尽记录了一个跨境电商 SaaS 系统对接 Shopee Open API V2 全过程所踩过的所有坑,涵盖应用注册、OAuth 授权、HMAC-SHA256 签名算法(三种类型!)、API 地区差异、参数命名混乱、本土店/跨境店差异、Token 生命周期管理、Apifox 前置脚本调试、字段映射等十余类问题。每一个坑都附有现象、根因分析、解决方案与反思。
适用读者: 准备或正在对接 Shopee Open Platform 的开发者
📖 目录
- 一、前期准备:应用注册与回调地址
- 二、授权流程:越简单越好
- 三、签名算法:三种 API 三种算法
- 四、API 地区差异:同一个接口不同域名
- 五、参数命名的混乱:一个字段三个名字
- 六、本土店 vs 跨境店:API 可用性的巨大鸿沟
- 七、Token 生命周期管理
- 八、15 天的时间范围限制
- 九、结算单 48 列的字段映射血泪史
- 十、调试工具踩坑:Apifox 前置脚本
- 十一、前端下载文件名被硬编码的坑
- 十二、架构层面的反思
- 十三、总结与最佳实践
一、前期准备:应用注册与回调地址
1.1 回调地址的坑:必须是完整域名
问题现象
在 Shopee Open Platform 控制台创建应用后,配置回调地址(Callback URL)。按照常规思路,我们配置了:
https://www.xxx.com/shopee-callback
结果无论如何操作,授权后都报错。Shopee 没有给出明确的错误信息,只是授权页面闪一下就跳回了首页,没有任何参数。
根因分析
经过多次测试和文档查阅,发现 Shopee 对回调地址做了严格的域名匹配校验。校验规则是:
回调地址必须是一级域名,不能带路径
也就是说:
| 写法 | 结果 |
|---|---|
https://www.xxx.com/shopee-callback | ❌ 失败 |
https://www.xxx.com/ | ✅ 成功 |
https://xxx.shopee.com/xxx | ❌ 失败 |
Shopee 在验证回调地址时,会把配置的 URL 与跳转时的 URL 做精确匹配,带路径的配置会导致验证失败。
解决方案
回调地址只填域名本身:
回调地址:https://www.xxx.com/
然后在自己的 Nginx 上做路径重定向,把首页请求转发到实际的处理地址。
1.2 Nginx 跳转的正确姿势
问题现象
一开始我们用 rewrite ... last 在 Nginx 中处理跳转:
# ❌ 错误做法
location = / {
rewrite ^ /shopee-callback last;
}
结果 Shopee 授权成功后跳转到了首页,但页面白屏,没有任何反应。
根因分析
rewrite ... last 是内部重定向(internal redirect),浏览器 URL 不变(还是 /),但 Nginx 内部把请求转给了 /shopee-callback。Shopee 回调带的 ?code=xxx&shop_id=xxx 参数在浏览器地址栏中可见,但由于是内部重定向,前端 React Router 接收到的路径是 / 而不是 /shopee-callback,无法匹配到对应的回调处理组件,导致白屏。
正确做法
使用 return 302 做外部重定向:
# ✅ 正确做法
location = / {
return 302 /shopee-callback$is_args$query_string;
}
这样浏览器地址栏会变成 /shopee-callback?code=xxx&shop_id=xxx,React Router 能正常匹配到回调处理组件。
完整 Nginx 配置
# Shopee 回调:首页 → 跳转到回调处理地址
location = / {
return 302 /shopee-callback$is_args$query_string;
}
# 代理后端 API
location /api {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
反思
Shopee 的回调地址校验规则跟大多数 OAuth 平台不一样。通常的 OAuth 平台(如 GitHub、Google)允许在回调地址中带路径,Shopee 却要求必须是纯域名。这个差异让我们折腾了整整两天。
二、授权流程:越简单越好
2.1 全量参数透传模式
问题现象
Shopee 的 OAuth 授权回调参数因场景不同而变化:
- 沙箱环境:
?code=xxx&main_account_id=yyy - 正式环境:
?code=xxx&shop_id=123&main_account_id=yyy - 子账号授权:
?code=xxx&shop_id=123
初期我们针对不同场景写了不同的处理逻辑,代码越来越复杂,还容易遗漏参数。
解决方案
全量参数透传——不管回调带什么参数,全部收下来,后端只取自己需要的:
// 前端:URL 上所有参数一次性 POST 给后端
const allParams = Object.fromEntries(searchParams.entries());
const data = await api('/api/platform/shopee/check-shop', {
method: 'POST',
body: JSON.stringify(allParams),
});
// 后端:只取需要的,多余的不管
export async function exchangeCode(params: any, region: string) {
const body = {
code: params.code,
partner_id: Number(creds.partnerId),
};
// shop_id 在沙箱可能没有,有则带上
if (params.shop_id !== undefined) {
body.shop_id = params.shop_id;
}
// 其他参数(main_account_id 等)不关心
}
这样未来 Shopee 加参数也不影响现有逻辑。
2.2 沙箱 vs 生产环境的授权差异
问题现象
沙箱环境的授权和正式环境的授权返回的参数不同:
| 环境 | 回调参数 |
|---|---|
| 沙箱 | code, main_account_id |
| 正式 | code, shop_id, main_account_id |
沙箱环境不返回 shop_id!如果代码里硬编码要求 shop_id 必传,沙箱授权会直接失败。
解决方案
参数全量透传 + 后端加判空:
if (params.shop_id !== undefined) {
body.shop_id = params.shop_id;
}
反思
沙箱环境和生产环境的差异不仅仅是域名不同,连返回的字段数量都可能不一样。开发阶段必须同时用沙箱和正式环境测试。 仅靠沙箱测试通过的代码,直接上生产可能出问题。
三、签名算法:三种 API 三种算法
3.1 官方文档的获取(CDP 的血泪史)
问题现象
在调研阶段,需要阅读 Shopee 官方文档中的签名算法说明。Shopee 的文档站点(open.shopee.com)是一个 Nuxt.js SPA(单页应用),直接 HTTP GET 获取到的页面内容只有 <title>Shopee Open Platform</title>,真正的文档内容是通过 JavaScript 动态渲染的。
尝试了以下方式:
web_fetch(HTTP GET)→ 只拿到标题- CDP
Runtime.evaluate读取document.body.innerText→ 需要等 JS 渲染完成 - CDP
Page.captureScreenshot→ 能截图但内容需要手动阅读
解决方案
Edge 浏览器 CDP 调试:
// 1. 通过 chrome-remote-interface 连接到 Edge DevTools
const CDP = require('chrome-remote-interface');
const client = await CDP({ port: 9222 });
// 2. 创建新标签页(继承已有 cookie,无需重新登录)
const { targetId } = await client.Target.createTarget({
url: 'https://open.shopee.com/developer-guide/16',
});
// 3. 等待 Nuxt.js 渲染(至少 6 秒)
await sleep(6000);
// 4. 读取渲染后的页面内容
const { result } = await client.Runtime.evaluate({
expression: 'document.body.innerText',
});
// 5. 提取签名算法相关的章节
关键经验
- browser-level WS 优于 page-level WS(避免 DevTools 冲突)
- SPAs 需要较长等待时间(6~10 秒)让 JavaScript 完成渲染
- 优先用
Target.createTarget创建新标签页(继承 cookie 无需登录) - 截图(
Page.captureScreenshot)+ 图片分析也是有效手段
3.2 Shop API 签名
问题现象
调用 get_order_list 等业务接口时,按照直觉写了签名逻辑,返回 Wrong sign。
官方签名算法
Shop API 用于订单、商品、物流、结算等需要店铺上下文的接口。公共参数需要 access_token 和 shop_id。
base string = partner_id + path + timestamp + access_token + shop_id
sign = HMAC-SHA256(base string, partner_key).toHex()
示例
partner_id = "your_partner_id"
path = "/api/v2/shop/get_shop_info"
timestamp = "1655714431"
access_token = "59777174636562737266615546704c6d"
shop_id = "14701711"
base string = "your_partner_id/api/v2/shop/get_shop_info165571443159777174636562737266615546704c6d14701711"
sign = HMAC-SHA256(base string, partner_key).digest('hex')
代码实现
function makeSign(
partnerId: string,
path: string,
timestamp: number,
partnerKey: string,
accessToken?: string,
shopId?: number,
): string {
let baseString = partnerId + path + timestamp;
if (accessToken) baseString += accessToken;
if (shopId !== undefined) baseString += String(shopId);
return crypto.createHmac('sha256', partnerKey).update(baseString).digest('hex');
}
3.3 Merchant API 签名
用于跨境商家接口(本地卖家一般不用)。签名方式和 Shop API 类似,只是把 shop_id 换成 merchant_id。
base string = partner_id + path + timestamp + access_token + merchant_id
3.4 Public API 签名
问题现象
调 refresh_access_token 接口时,用了跟业务 API 一样的签名逻辑,结果始终返回 Wrong sign。排查了几个小时,才发现——这个接口不需要 access_token 和 shop_id 参与签名。
官方签名算法
Public API 用于获取 Token、刷新 Token 等认证接口。它不需要 access_token 和 shop_id,因为调用时你还没有这些值。
base string = partner_id + path + timestamp
没有 access_token,没有 shop_id!只有 partner_id、path、timestamp 三个东西!
代码实现
// 刷新 token 时,用 Public API 签名规则(不带 access_token 和 shop_id)
const baseString = partnerId + '/api/v2/auth/access_token/get' + timestamp;
const sign = crypto.createHmac('sha256', partnerKey).update(baseString).digest('hex');
这个坑为什么这么隐蔽?
因为我们的签名函数为了通用性,写成了带 if 条件的:
let baseString = partnerId + path + timestamp;
if (accessToken) baseString += accessToken; // Public API:accessToken为空,不拼
if (shopId !== undefined) baseString += String(shopId); // Public API:shopId为空,不拼
看起来逻辑是对的——为空就不拼。但实际上,Shopee 服务端计算 sign 时,不管参数有没有值,base string 始终是完整的 partner_id + path + timestamp + access_token + shop_id,没有 access_token 就拼空字符串。
意味着客户端的 base string = partnerId + path + ts,而服务端的 base string = partnerId + path + ts + "" + "",两个字符串不同,sign 不匹配。
最终验证: 经过实际测试,Public API 的 base string 确实只有三个要素,不需要空字符串占位。但我们的代码也正确做了判空跳过。所以这个坑的真正原因其实是参数名前导斜杠问题(见 3.6),跟空字符串无关。
3.5 签名对照表
| API 类型 | 公共参数 | base string |
|---|---|---|
| Shop API | partner_id + timestamp + sign + access_token + shop_id | partnerId + /path + timestamp + accessToken + shopId |
| Merchant API | partner_id + timestamp + sign + access_token + merchant_id | partnerId + /path + timestamp + accessToken + merchantId |
| Public API | partner_id + timestamp + sign(无 access_token) | partnerId + /path + timestamp |
3.6 前导斜杠的血泪教训
问题现象
签名算法看起来都对,base string 也没算错,但 Shopee 始终返回 Wrong sign。在 Apifox 中调试,console.log 打印出来的 base string 看起来完全正常,可就是通不过。
根因分析
百思不得其解时,打开了 Shopee Developer Guide 页面,一行一行对比文档示例。终于发现了问题:
签名用的 path 必须带前导斜杠 /!
❌ 错误: "api/v2/order/get_order_list"
✅ 正确: "/api/v2/order/get_order_list"
我们提取 path 的代码是这样的:
const baseUrl = urlStr.split("?")[0];
const apiPath = baseUrl.replace(/^https?:\/\/[^\/]+\/?/, "");
// 结果: "api/v2/auth/access_token/get" ← 少了前面的斜杠!
这个正则提取的是 https://domain.com/xxx → xxx,去掉了协议和域名,也顺便去掉了第一个斜杠。
解决方案
// 方案一:加一个斜杠
const apiPath = "/" + baseUrl.replace(/^https?:\/\/[^\/]+\/?/, "");
// 方案二(更稳):用 URL 对象
const urlObj = new URL(pm.request.url.toString());
const apiPath = urlObj.pathname; // 自带 "/api/v2/auth/access_token/get"
反思
一个正斜杠的差异,让我们排查了整整一天。更令人沮丧的是——get_order_list 这些业务接口虽然也有同样的 bug,但因为它们同时还拼接了 access_token 和 shop_id,所以 sign 计算结果竟然碰巧对了(可能因为多了几段字符串,服务端做了兼容处理)。到了 Public API 这三个字段的简洁版本,bug 才暴露出来。
这就是关联性测试的陷阱——一个 bug 被其他参数掩盖了,只会在特定条件下暴露。
四、API 地区差异:同一个接口不同域名
Shopee 在不同地区部署了不同的 API 服务器:
| 环境 | 域名 | 说明 |
|---|---|---|
| 生产(新加坡) | xxx.shopee.com | 面向全球部署的开发者 |
| 生产(中国大陆) | xxx.shopee.cn | 面向中国大陆部署的开发者 |
| 生产(巴西) | xxx.shopee.com.br | 面向美洲部署的开发者 |
| 沙箱(通用) | sandbox.shopee.com | 通用沙箱 |
| 沙箱(中国大陆) | sandbox.shopee.cn | 大陆开发者沙箱 |
踩坑经历
我们一开始的代码里把 API 域名硬编码为 sandbox.shopee.com(沙箱),后来切换到生产环境时忘记改成 xxx.shopee.com,导致所有请求都 404。
更坑的是,不能简单地在代码里写死一个域名,因为沙箱环境和生产环境的 App ID、Partner ID、Partner Key 是不同的,需要根据环境动态切换。
解决方案
// config/platforms/shopee.ts
export const SHOPEE_API_HOST = 'xxx.shopee.com'; // 生产环境
export async function getShopeeCredentials(region: string) {
// 从数据库 platform_apps 表动态获取配置
const [rows] = await pool.query(
`SELECT id, app_key, app_secret, region FROM platform_apps
WHERE platform = 'shopee' AND region = ? AND is_active = 1`,
[region]
);
return {
partnerId: rows[0].app_key,
partnerKey: rows[0].app_secret,
apiHost: SHOPEE_API_HOST,
};
}
五、参数命名的混乱:一个字段三个名字
Shopee 的 API 参数命名风格完全没有统一规范,同一个"订单号"在不同接口中可能叫不同的名字:
5.1 get_order_list:极简主义
路径: GET /api/v2/order/get_order_list
返回值: 只有 order_sn 和 booking_sn,没有金额、没有状态、没有时间。
{
"response": {
"order_list": [
{ "order_sn": "YYYYMMDDXXXXXX", "booking_sn": "" }
]
}
}
注意: response.order_list 的字段名是 order_list,不是 orders!第一次写代码时我们用了 data.orders,结果一直返回空数据。
5.2 get_order_detail:参数名踩坑
路径: GET /api/v2/order/get_order_detail
参数名迷宫: 这个接口的参数到底叫什么?我们踩了三次:
| 尝试的参数 | API 的反馈 |
|---|---|
ordersn_list | order_sn_list is empty string(参数名不对) |
order_sn_list | the order is not found(参数名对了但订单查不到) |
order_sn_list(POST 方式) | 404 page not found(只能用 GET) |
最终发现——get_order_detail 对泰国本土店根本不可用,无论参数名怎么拼,都返回 order is not found。
5.3 get_escrow_detail:单数不是复数
路径: GET /api/v2/payment/get_escrow_detail
这个接口的参数更离谱——参数叫 order_sn(单数),不是象其他接口那样的列表。
// ❌ 错误(受 get_order_detail 影响)
queryParams: { order_sn_list: JSON.stringify([orderId]) }
// API返回: Missing order_sn
// ✅ 正确(单数就行)
queryParams: { order_sn: orderId }
// 返回: 完整的费项数据!
为什么同一个平台,一个用 order_sn_list,一个用 order_sn?没有为什么,Shopee 的 API 设计就是不统一的。
5.4 get_escrow_list:这才是我们需要的
路径: GET /api/v2/payment/get_escrow_list
参数: release_time_from, release_time_to(15 天限制)
这个接口返回的是按**结算时间(release_time)**筛选的订单列表。它的返回值比 get_order_list 丰富得多:
{
"escrow_list": [
{
"order_sn": "YYYYMMDDXXXXXX",
"escrow_release_time": 1782699547,
"payout_amount": 41
}
]
}
有订单号、有结算时间、有结算金额——这三样东西是财务核算的核心,一个 API 全搞定。
参数名总结
| API | 参数名(订单号相关) | 参数格式 |
|---|---|---|
get_order_list | N/A(不传订单号) | — |
get_order_detail | order_sn_list | JSON 数组字符串 |
get_escrow_detail | order_sn(单数!) | 纯字符串 |
get_shipment_list | order_sn | 纯字符串 |
get_tracking_number | order_sn | 纯字符串 |
教训
不要凭经验猜测参数名。 同一个参数在不同接口中可能叫不同的名字,必须以官方文档页面上每个接口的 Parameters 表格为准。
六、本土店 vs 跨境店:API 可用性的巨大鸿沟
6.1 检测到的差异清单
通过实际调用,我们扫描了泰国本土店(shop_id=1234567890)的 API 可用性:
| API | 本土店 | 跨境店 | 备注 |
|---|---|---|---|
get_order_list | ✅ | ✅ | 但只返回 order_sn |
get_order_detail | ❌ order not found | ✅ | 本土店不可用 |
get_escrow_detail | ✅ | ✅ | 参数 order_sn(单数) |
get_escrow_list | ✅ | ✅ | 按结算时间筛选 |
get_shipment_list | ✅ | ✅ | 含 package_number |
get_tracking_number | ✅ | ✅ | 含 tracking_number |
get_payout | ❌ error_not_found | ✅ | 查無结算流水 |
get_payout_detail | ❌ 仅跨境店可用 | ✅ | 明确限制 |
get_shop_info | ✅ | ✅ | 可用 |
get_profile | ✅ | ✅ | 含 shop_name、description |
get_package_detail | ⚠️ 空数据 | ✅ | 需要有效 package_number |
get_shipping_parameter | ⚠️ 仅出库前可用 | ✅ | 订单已发货后不可用 |
6.2 缺失 API 的替代方案
由于 get_order_detail 不可用,我们无法获取订单的物流信息(Shipping provider、Courier Name)。
经过全面扫描,我们确定:
get_escrow_detail❌ 不包含物流商名称get_shipment_list❌ 只返回 package_numberget_tracking_number❌ 只返回 tracking_numberget_package_detail❌ 空数据- 其他接口 ❌ 均不包含物流信息
结论: 对于泰国本土店,Shipping provider 和 Courier Name 无法通过 Open API 获取。可能的解决方案:
- 等待 Shopee API 更新
- 使用跨境店账号获取
- 通过 Shopee 卖家中心手动导出
七、Token 生命周期管理
7.1 Token 时效说明
| Token | 有效期 | 获取方式 | 说明 |
|---|---|---|---|
access_token | 4 小时 | 授权码换取 | 调用所有业务 API 都需要 |
refresh_token | 30 天 | 随 access_token 一起获得 | 过期后需重新走授权流程 |
4 小时的 access_token 有效期非常短。 如果你的业务逻辑涉及大量 API 调用(如批量财务核算运行超过 4 小时),就必须有自动刷新机制。
7.2 刷新接口的正确打开方式
踩坑经历
第一次调刷新接口时,我们犯了两个错误:
错误 1:用了 Shop API 签名规则
// ❌ 错误(用 Shop API 签名)
const baseString = partnerId + path + timestamp + accessToken + shopId;
// Public API 刷新 token 时还没有 access_token!
错误 2:把参数放错了位置
// ❌ 错误:把 refresh_token 放在 query 参数中
// 实际上 refresh_token 应该放在 POST body 中
正确调用方式
POST /api/v2/auth/access_token/get
Query: partner_id, timestamp, sign(用 Public API 签名规则)
Body: { refresh_token, partner_id, shop_id }
// 步骤 1:Public API 签名(不带 access_token/shop_id)
const baseString = partnerId + '/api/v2/auth/access_token/get' + timestamp;
const sign = crypto.createHmac('sha256', partnerKey).update(baseString).digest('hex');
// 步骤 2:查询参数
const query = `partner_id=${partnerId}×tamp=${timestamp}&sign=${sign}`;
// 步骤 3:POST Body(refresh_token 必须放在 body 中!)
const body = JSON.stringify({
refresh_token: refreshToken,
partner_id: Number(partnerId),
shop_id: shopId,
});
正确的 Query 参数
刷新 token 时,query 中只有 partner_id、timestamp、sign 三个参数,不能有 access_token 和 shop_id(因为是 Public API)。
7.3 自动刷新机制的实现
我们在 callShopeeApi 函数中实现了自动刷新:
export async function callShopeeApi<T>(options: ShopeeApiOptions): Promise<T> {
// ... 发送请求 ...
const json = JSON.parse(data);
// 检测到 token 过期 → 自动刷新
if (json.error === 'invalid_acceess_token') {
const newToken = await refreshAccessToken(shopId, region, creds);
// 用新 token 重试
const retryResult = await callShopeeApi<T>({ ...options, accessToken: newToken });
resolve(retryResult);
return;
}
// ... 正常处理 ...
}
7.4 内存缓存避免重复刷新
问题现象
自动刷新实现了,但新的问题出现了——每次 API 调用都触发一次刷新!
原因很简单:Step 1 调用 get_escrow_list 时刷新了 token,但 Step 2 调用 get_escrow_detail 时用的还是原来那个过期的 accessToken 参数,所以又触发了刷新。37 笔订单,37 次刷新。
解决方案:内存缓存
// Token 内存缓存
const tokenCache = new Map<number, { token: string; expiresAt: number }>();
export async function callShopeeApi<T>(options: ShopeeApiOptions): Promise<T> {
const { shopId, accessToken } = options;
// 优先使用内存缓存的 token
const cached = tokenCache.get(shopId);
const effectiveToken = (cached && cached.expiresAt > Date.now() / 1000)
? cached.token // 缓存有效 → 用缓存的
: accessToken; // 没缓存 → 用传进来的
// ... 用 effectiveToken 构建请求 ...
// 刷新成功后,更新内存缓存
if (json.error === 'invalid_acceess_token') {
const newToken = await refreshAccessToken(shopId, region, creds);
tokenCache.set(shopId, {
token: newToken,
expiresAt: Math.floor(Date.now() / 1000) + 14400, // 4 小时
});
// ... 重试 ...
}
}
现在只有第一次 API 调用会触发刷新,后面 36 次直接从内存缓存读取新 token,不再触发刷新。
八、15 天的时间范围限制
问题现象
第一次调 get_escrow_list 时,传了 30 天的时间范围,结果 API 返回错误:
invalid time range, please ensure payout_time_to is greater than payout_time_from but within 15 days
原因
Shopee 的多个 API 对时间查询范围有严格的 15 天上限:
| API | 参数 | 时间范围限制 |
|---|---|---|
get_escrow_list | release_time_from/to | ≤ 15 天 |
get_payout_detail | payout_time_from/to | ≤ 15 天 |
如果超过 15 天,API 会直接拒绝请求。
解决方案
如果需要查询超过 15 天的数据(如整个月的结算单),需要分段查询:
async function queryEscrowListByMonth(startDate: string, endDate: string) {
let current = new Date(startDate);
const end = new Date(endDate);
const allOrders = [];
while (current < end) {
// 每次最多查 15 天
const segmentEnd = new Date(Math.min(
current.getTime() + 14 * 86400 * 1000,
end.getTime()
));
const orders = await getEscrowList({
release_time_from: Math.floor(current.getTime() / 1000),
release_time_to: Math.floor(segmentEnd.getTime() / 1000) + 86400,
});
allOrders.push(...orders);
current = new Date(segmentEnd.getTime() + 86400 * 1000);
}
return allOrders;
}
九、结算单 48 列的字段映射血泪史
9.1 响应体结构的探索过程
第一次尝试:凭经验写代码
打开 Shopee API 文档,看了 get_escrow_detail 的参数说明,觉得很简单,直接按文档字段名写代码:
const detail = data.orders?.[0];
// 取字段
const revenue = detail.total_released_amount;
const commission = detail.commission_fee;
结果: 全部 undefined。API 返回的数据结构跟我们想的不一样。
第二次尝试:打印全部字段
把 API 返回的 JSON 整个打印出来看,发现响应体结构是:
{
"error": "",
"message": "",
"response": {
"buyer_payment_info": { ... },
"buyer_user_name": "tichacha2018",
"order_income": {
"commission_fee": 37,
"service_fee": 28,
"escrow_amount": 255,
"items": [
{ "item_sku": "riasjfwe", "original_price": 739, ... }
],
...
},
"order_sn": "YYYYMMDDXXXXXX"
}
}
原来 commission_fee 在 response.order_income.commission_fee,不是 data.orders[0].commission_fee!
第三次尝试:对比官方 Excel
拿到 Shopee 官方下载的 Income Report Excel,逐列对比 JSON 字段。打开 Excel 看列名,然后在 JSON 中搜索对应的值,一个一个找。
这个过程极其耗时——Excel 有 48 列,JSON 有 80+ 个字段,靠肉眼逐行对比。
最终流程
真实 API 调用 → 打印完整 JSON → 导出官方 Excel →
逐列对比 JSON 字段 → 确认映射 → 写入代码
9.2 字段映射表(已确认)
以下是已确认的 44 个字段映射(共 48 列):
| 列 | 列名 | JSON 路径 | 取值 |
|---|---|---|---|
| A | Sequence No. | 自增 | seq++ |
| B | Order ID | order_sn | entry.orderSn |
| C | refund id | buyer_payment_info.cash_refund_to_buyer_amount | 有退款时生成 REF_${orderSn} |
| D | Username (Buyer) | response.buyer_user_name | buyerInfo?.buyer_user_name |
| E | Order Creation Date | 从 order_sn 解析 | 20${sn[0-2]}-${sn[2-4]}-${sn[4-6]} |
| F | Buyer Payment Method | buyer_payment_info.buyer_payment_method | buyerInfo?.buyer_payment_method |
| G | Hot Listing | 固定值 | 'NO' |
| I | Payment Details / Installment Plan | order_income.instalment_plan | income?.instalment_plan |
| K | Payout Completed Date | get_escrow_list.escrow_release_time | releaseTime |
| L | Original product price | items[].original_price(聚合) | totalL |
| M | Your Seller product promotion | items[].seller_discount(聚合) | -Math.abs(totalM) |
| N | Refund Amount | order_income.seller_return_refund | -Math.abs(income.seller_return_refund) |
| O | Rebate Provided by Shopee | 固定 0 | 0 |
| P | Voucher Sponsored by Seller | order_income.voucher_from_seller | -Math.abs(income.voucher_from_seller) |
| R | Coin Cashback Sponsored by Seller | order_income.seller_coin_cash_back | -Math.abs(income.seller_coin_cash_back) |
| T | Shipping Fee Paid by Buyer | order_income.buyer_paid_shipping_fee | income.buyer_paid_shipping_fee |
| U | Shipping Rebate From Shopee | order_income.shopee_shipping_rebate | income.shopee_shipping_rebate |
| V | Actual Shipping Fee | order_income.actual_shipping_fee | -Math.abs(income.actual_shipping_fee) |
| W | Reverse Shipping Fee | order_income.reverse_shipping_fee | -Math.abs(income.reverse_shipping_fee) |
| X | Return to Seller Shipping Fee | order_income.return_to_seller_shipping_fee | -Math.abs() |
| Y | Saver Program Shipping Fee Savings | order_income.shipping_fee_discount_from_3pl | income.shipping_fee_discount_from_3pl |
| Z | AMS Commission Fee | order_income.ams_commission_fee | -Math.abs() |
| AA | Commission fee | order_income.commission_fee | -Math.abs() |
| AB | Service Fee | order_income.service_fee | -Math.abs() |
| AE | Transaction Fee | order_income.seller_transaction_fee | -Math.abs() |
| AF | Custom Tax | order_income.cross_border_tax | -Math.abs() |
| AG | Ads Credit Top-Up (Escrow) | order_income.ads_escrow_top_up_fee_or_technical_support_fee | -Math.abs() |
| AH | Buyer Paid Installation Fee | order_income.installation_fee_paid_by_buyer | income.installation_fee_paid_by_buyer |
| AI | Actual Installation Fee | order_income.actual_installation_fee | income.actual_installation_fee |
| AJ | Trade-in Bonus by Seller | order_income.trade_in_bonus_by_seller | -Math.abs() |
| AK | Total Released Amount (฿) | order_income.escrow_amount | income.escrow_amount |
| AL | Voucher Code From Seller | order_income.seller_voucher_code | 数组取第一项 |
| AM | Lost Compensation | order_income.seller_lost_compensation | income.seller_lost_compensation |
| AN | Shipping Fee Promotion by Seller | order_income.seller_shipping_discount | -Math.abs() |
| AP | Cash refund to buyer amount | buyer_payment_info.cash_refund_to_buyer_amount | -Math.abs() |
| AQ~AT | Pro-rated * 系列(4个) | order_income.prorated_* | 直接取值 |
特别注意:O 列和 U 列的踩坑
一开始我们的代码是这样的:
row[14] = getIncome(income, 'shopee_shipping_rebate'); // O列: Rebate Provided by Shopee
row[20] = getIncome(income, 'shopee_shipping_rebate'); // U列: Shipping Rebate From Shopee
O 和 U 取了同一个字段。但官方 Excel 中,O 列是 0,U 列才是 38(有值)。
分析: shopee_shipping_rebate 是"运费补贴",应该放在 U 列。O 列"Rebate Provided by Shopee"可能指的是另一种补贴,或者这个字段对本土店就是 0。
所以 O 列改成了固定 0。
9.3 字段映射表(待确认)
以下 6 个字段经过多个 API 的扫描,确认 API 中没有对应数据:
| 列 | 列名 | 状态 | 说明 |
|---|---|---|---|
| H | Buyer Payment Method Details_1 | ❌ API 无字段 | 固定空值 |
| J | Transaction Fee Rate (%) | ❌ API 无字段 | 费率为空 |
| Q | Cofund Voucher Sponsored by Seller | ❌ API 无字段 | 固定 0 |
| S | Cofund Coin Cashback Sponsored by Seller | ❌ API 无字段 | 固定 0 |
| AC | Platform Infrastructure Fee | ❌ API 无字段 | 固定 0 |
| AD | Saver Program Fee | ❌ API 无字段 | 固定 0 |
| AT | Shipping provider | ❌ 需其他 API | get_order_detail(本土店不可用) |
| AU | Courier Name | ❌ 需其他 API | get_order_detail(本土店不可用) |
9.4 去重与聚合逻辑
问题现象
get_escrow_list 可能返回同一个 order_sn 多次(分批次结算、部分退款等)。如果不做去重,Excel 中同一个订单号会出现多次。
解决方案
使用 Map<string, any> 按 order_sn 聚合:
const detailMap = new Map<string, any>();
for (const order of orders) {
const orderSn = order.orderSn;
const items = escrowDetail.income.items || [];
// 聚合 L 列和 M 列
let totalOriginalPrice = 0;
let totalSellerDiscount = 0;
for (const item of items) {
totalOriginalPrice += n(item.original_price);
totalSellerDiscount += n(item.seller_discount);
}
if (detailMap.has(orderSn)) {
// 已存在 → 累加 L 和 M
const exist = detailMap.get(orderSn);
exist.totalL += totalOriginalPrice;
exist.totalM += totalSellerDiscount;
} else {
// 第一次出现
detailMap.set(orderSn, {
orderSn,
releaseTime: order.releaseTime,
income: escrowDetail.income,
buyerInfo: escrowDetail.buyerInfo,
totalL: totalOriginalPrice,
totalM: totalSellerDiscount,
});
}
}
核心逻辑:
- 同一订单多次出现 → L 列(Original product price)+ M 列(Your Seller product promotion)的值累加
- 其他列(佣金、服务费、交易费等)→ 取第一次出现的值
- 最终一个订单只输出一行
十、调试工具踩坑:Apifox 前置脚本
10.1 CryptoJS 已废弃
Apifox 的控制台提示:
Using "CryptoJS" is deprecated. Use global "crypto" object instead.
// ❌ 废弃用法
const sign = CryptoJS.HmacSHA256(baseString, partnerKey).toString(CryptoJS.enc.Hex);
// ✅ 推荐用法
const sign = crypto.createHmac('sha256', partnerKey).update(baseString).digest('hex');
10.2 URL 参数操作的坑
Apifox 的 pm.request.query.remove() 方法不存在:
// ❌ 不存在
pm.request.query.remove("sign");
// ✅ 正确:用 URLSearchParams 操作
const urlObj = new URL(pm.request.url.toString());
const params = new URLSearchParams(urlObj.search);
params.delete("sign");
params.set("partner_id", partnerId);
params.set("timestamp", String(timestamp));
10.3 接口路径获取的坑
pm.request.url.getPath() 返回双斜杠:
pm.request.url.getPath(); // "//api/v2/auth/token/get" ← 双斜杠!
// ✅ 正确:用 URL 对象
new URL(pm.request.url.toString()).pathname; // "/api/v2/auth/token/get"
10.4 最终脚本
// Shopee API V2 HMAC-SHA256 签名计算
const partnerKey = pm.environment.get("partner_key") || "";
const partnerId = pm.environment.get("partner_id") || "";
const timestamp = Math.floor(Date.now() / 1000);
// 用 URL 对象取路径(自带前导斜杠)
const urlObj = new URL(pm.request.url.toString());
const apiPath = urlObj.pathname;
// 安全操作 query 参数
const params = new URLSearchParams(urlObj.search);
params.set("partner_id", partnerId);
params.set("timestamp", String(timestamp));
params.delete("sign");
const accessToken = pm.variables.get("access_token") || "";
const shopId = pm.variables.get("shop_id") || "";
let baseString = partnerId + apiPath + timestamp;
// Shop API 需要 access_token 和 shop_id,Public API 不需要
if (accessToken) baseString += accessToken;
if (shopId) baseString += shopId;
const sign = crypto.createHmac('sha256', partnerKey).update(baseString).digest('hex');
params.set("sign", sign);
pm.request.url = urlObj.origin + urlObj.pathname + "?" + params.toString();
十一、前端下载文件名被硬编码的坑
问题现象
后端生成的文件名明明是 Shopee_ShopName_泰国_结算单_2026-06-22-2026-06-22.xlsx,但浏览器下载后文件名变成了 ShopName_订单_2026-06-22-2026-06-22.xlsx。
根因分析
前端 OverseasFinance.tsx 的下载代码中,硬编码了文件名:
// ❌ 硬编码文件名,完全忽略后端生成的文件名
a.download = `${selectedShopInfo?.name || 'shop'}_订单_${calcOrderStart}-${calcOrderEnd}.xlsx`;
无论后端生成什么文件名,下载时都被替换成这个固定格式。TikTok 也一样被重命名了。
修复
// ✅ 直接用后端返回的文件名
a.download = data.result.orderFile;
这样:
- Shopee → 下载
Shopee_ShopName_泰国_结算单_2026-06-22-2026-06-22.xlsx - TikTok → 下载
ShopName_泰国_2026-06-22-2026-06-22全部订单_.xlsx
十二、架构层面的反思
12.1 Adapter 模式解耦平台差异
多个平台(TikTok、Shopee)的对接让我们深刻体会到 Adapter 模式的价值:
interface PlatformAdapter {
searchOrders(params): Promise<OrderSearchResult>;
getOrder(shop, orderId): Promise<StandardOrder>;
getStatement(shop, orderId): Promise<StandardFinance>;
// ...其他方法
}
每个平台各自实现 PlatformAdapter,业务代码只依赖接口,不依赖具体实现:
const adapter = platformFactory.getAdapter(platform);
const orders = await adapter.searchOrders(params);
12.2 Workflow 模式拆分步骤
财务核算是一个长流程,我们将它拆分成独立的步骤(Step 1 ~ Step N):
Step 1: get_escrow_list(按结算时间查订单)
Step 2: get_escrow_detail(获取费项明细 + 去重聚合)
Step 3: 生成 Income Report Excel
Step 4: (预留) 生成资金账单
Step 5: (预留) 生成纳华报表
每步可单独测试、单独优化。
12.3 自动签名 + 自动刷新 Token
callShopeeApi 函数封装了所有跨切面关注点:
- 从数据库动态获取 Partner ID / Key
- 自动 HMAC-SHA256 签名
- 自动检测 token 过期并刷新
- 内存缓存避免重复刷新
- 重试机制(继承自 retryCall)
12.4 实践注意
- Token 内存缓存 — 同一次核算中只触发一次 token 刷新
- 数据去重 —
get_escrow_list可能返回重复订单,用 Map 聚合 - 文件名传递 — 前端下载时直接用后端返回的文件名,不硬编码
十三、总结与最佳实践
13.1 与 Shopee API 对接的核心原则
- 三种签名算法必须区分 — Shop API、Merchant API、Public API 各有不同的 base string 规则
- 参数名前导斜杠必须保留 —
/api/v2/xxx不能写成api/v2/xxx - 以实际 API 响应为准 — 文档可能不准确,字段映射以真实 JSON 为准
- Token 管理必须有自动刷新 — 4 小时有效期很短
- 区分店铺类型 — 本土店和跨境店可用的 API 不同
13.2 字段映射的黄金法则
- 搭建测试环境 → 真实调用 → 拿到完整 JSON 响应
- 导出 Shopee 官方 Excel → 逐列对比 JSON 字段
- 确认字段类型、嵌套层级、是否可为 null
- 写字段映射表(已确认 + 待确认分开)
- 如果 API 响应与文档不一致,以响应为准
13.3 给新手的 10 条建议
- 回调地址只写域名 — Shopee 要求回调地址必须是纯域名,不要带路径
- 授权过程全量透传 — 不要分别处理不同场景的参数,全部透传给后端
- 查签名算法先看 Developer Guide — 花 10 分钟看
developer-guide/16能省 3 天排查时间 - 参数名以文档为准 —
order_sn、order_sn_list、ordersn_list是不同的东西 - 响应体结构看清楚 —
response.xxx.yyy和直接data.yyy是不同的 - 15 天限制注意 — 查询大范围数据时需分段
- Apifox 脚本用 URL 对象 —
new URL()比正则更可靠 - 前导斜杠不能丢 — 签名用的 path 必须以
/开头 - 本土店和跨境店分开测试 — 两者的 API 可用性差异很大
- 前端下载名不用硬编码 — 直接使用后端返回的文件名
13.4 我们的最终数据链路
get_escrow_list(release_time_from/to)
↓ { order_sn, escrow_release_time, payout_amount }
get_escrow_detail(order_sn) ← 参数是单数!
↓ { order_income, buyer_payment_info }
去重聚合(Map<orderSn> → 每个订单一行)
↓ totalL = sum(items.original_price), totalM = sum(items.seller_discount)
Income Report Excel(48列,44列有数据源)
最后的话: 对接 Shopee Open API 的过程,是一场与不确定性持续战斗。回调地址的校验规则、签名算法的三种变体、参数命名的随意性、本土店与跨境店的天壤之别——每一个坑都让人怀疑人生。但只要你掌握了规律,一切都变得清晰。不要凭经验猜测,一切以实际调用结果为准。 当你调通第一个 API 时,所有的挫折都是值得的。
5730

被折叠的 条评论
为什么被折叠?



