开发者对接 Shopee 平台 API 接口的血与泪教训

开发者对接 Shopee 平台 API 接口的血与泪教训

摘要: 本文详尽记录了一个跨境电商 SaaS 系统对接 Shopee Open API V2 全过程所踩过的所有坑,涵盖应用注册、OAuth 授权、HMAC-SHA256 签名算法(三种类型!)、API 地区差异、参数命名混乱、本土店/跨境店差异、Token 生命周期管理、Apifox 前置脚本调试、字段映射等十余类问题。每一个坑都附有现象、根因分析、解决方案与反思。

适用读者: 准备或正在对接 Shopee Open Platform 的开发者


📖 目录


一、前期准备:应用注册与回调地址

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 动态渲染的。

尝试了以下方式:

  1. web_fetch(HTTP GET)→ 只拿到标题
  2. CDP Runtime.evaluate 读取 document.body.innerText → 需要等 JS 渲染完成
  3. 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_tokenshop_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_tokenshop_id 参与签名

官方签名算法

Public API 用于获取 Token、刷新 Token 等认证接口。它不需要 access_tokenshop_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 APIpartner_id + timestamp + sign + access_token + shop_idpartnerId + /path + timestamp + accessToken + shopId
Merchant APIpartner_id + timestamp + sign + access_token + merchant_idpartnerId + /path + timestamp + accessToken + merchantId
Public APIpartner_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/xxxxxx,去掉了协议和域名,也顺便去掉了第一个斜杠。

解决方案
// 方案一:加一个斜杠
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_tokenshop_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_snbooking_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_listorder_sn_list is empty string(参数名不对)
order_sn_listthe 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_listN/A(不传订单号)
get_order_detailorder_sn_listJSON 数组字符串
get_escrow_detailorder_sn(单数!)纯字符串
get_shipment_listorder_sn纯字符串
get_tracking_numberorder_sn纯字符串
教训

不要凭经验猜测参数名。 同一个参数在不同接口中可能叫不同的名字,必须以官方文档页面上每个接口的 Parameters 表格为准。


六、本土店 vs 跨境店:API 可用性的巨大鸿沟

6.1 检测到的差异清单

通过实际调用,我们扫描了泰国本土店(shop_id=1234567890)的 API 可用性:

API本土店跨境店备注
get_order_list但只返回 order_sn
get_order_detailorder not found本土店不可用
get_escrow_detail参数 order_sn(单数)
get_escrow_list按结算时间筛选
get_shipment_list含 package_number
get_tracking_number含 tracking_number
get_payouterror_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_number
  • get_tracking_number ❌ 只返回 tracking_number
  • get_package_detail ❌ 空数据
  • 其他接口 ❌ 均不包含物流信息

结论: 对于泰国本土店,Shipping provider 和 Courier Name 无法通过 Open API 获取。可能的解决方案:

  1. 等待 Shopee API 更新
  2. 使用跨境店账号获取
  3. 通过 Shopee 卖家中心手动导出

七、Token 生命周期管理

7.1 Token 时效说明

Token有效期获取方式说明
access_token4 小时授权码换取调用所有业务 API 都需要
refresh_token30 天随 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}&timestamp=${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_idtimestampsign 三个参数,不能access_tokenshop_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_listrelease_time_from/to≤ 15 天
get_payout_detailpayout_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_feeresponse.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 路径取值
ASequence No.自增seq++
BOrder IDorder_snentry.orderSn
Crefund idbuyer_payment_info.cash_refund_to_buyer_amount有退款时生成 REF_${orderSn}
DUsername (Buyer)response.buyer_user_namebuyerInfo?.buyer_user_name
EOrder Creation Date从 order_sn 解析20${sn[0-2]}-${sn[2-4]}-${sn[4-6]}
FBuyer Payment Methodbuyer_payment_info.buyer_payment_methodbuyerInfo?.buyer_payment_method
GHot Listing固定值'NO'
IPayment Details / Installment Planorder_income.instalment_planincome?.instalment_plan
KPayout Completed Dateget_escrow_list.escrow_release_timereleaseTime
LOriginal product priceitems[].original_price(聚合)totalL
MYour Seller product promotionitems[].seller_discount(聚合)-Math.abs(totalM)
NRefund Amountorder_income.seller_return_refund-Math.abs(income.seller_return_refund)
ORebate Provided by Shopee固定 00
PVoucher Sponsored by Sellerorder_income.voucher_from_seller-Math.abs(income.voucher_from_seller)
RCoin Cashback Sponsored by Sellerorder_income.seller_coin_cash_back-Math.abs(income.seller_coin_cash_back)
TShipping Fee Paid by Buyerorder_income.buyer_paid_shipping_feeincome.buyer_paid_shipping_fee
UShipping Rebate From Shopeeorder_income.shopee_shipping_rebateincome.shopee_shipping_rebate
VActual Shipping Feeorder_income.actual_shipping_fee-Math.abs(income.actual_shipping_fee)
WReverse Shipping Feeorder_income.reverse_shipping_fee-Math.abs(income.reverse_shipping_fee)
XReturn to Seller Shipping Feeorder_income.return_to_seller_shipping_fee-Math.abs()
YSaver Program Shipping Fee Savingsorder_income.shipping_fee_discount_from_3plincome.shipping_fee_discount_from_3pl
ZAMS Commission Feeorder_income.ams_commission_fee-Math.abs()
AACommission feeorder_income.commission_fee-Math.abs()
ABService Feeorder_income.service_fee-Math.abs()
AETransaction Feeorder_income.seller_transaction_fee-Math.abs()
AFCustom Taxorder_income.cross_border_tax-Math.abs()
AGAds Credit Top-Up (Escrow)order_income.ads_escrow_top_up_fee_or_technical_support_fee-Math.abs()
AHBuyer Paid Installation Feeorder_income.installation_fee_paid_by_buyerincome.installation_fee_paid_by_buyer
AIActual Installation Feeorder_income.actual_installation_feeincome.actual_installation_fee
AJTrade-in Bonus by Sellerorder_income.trade_in_bonus_by_seller-Math.abs()
AKTotal Released Amount (฿)order_income.escrow_amountincome.escrow_amount
ALVoucher Code From Sellerorder_income.seller_voucher_code数组取第一项
AMLost Compensationorder_income.seller_lost_compensationincome.seller_lost_compensation
ANShipping Fee Promotion by Sellerorder_income.seller_shipping_discount-Math.abs()
APCash refund to buyer amountbuyer_payment_info.cash_refund_to_buyer_amount-Math.abs()
AQ~ATPro-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 中没有对应数据:

列名状态说明
HBuyer Payment Method Details_1❌ API 无字段固定空值
JTransaction Fee Rate (%)❌ API 无字段费率为空
QCofund Voucher Sponsored by Seller❌ API 无字段固定 0
SCofund Coin Cashback Sponsored by Seller❌ API 无字段固定 0
ACPlatform Infrastructure Fee❌ API 无字段固定 0
ADSaver Program Fee❌ API 无字段固定 0
ATShipping provider❌ 需其他 APIget_order_detail(本土店不可用)
AUCourier Name❌ 需其他 APIget_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 对接的核心原则

  1. 三种签名算法必须区分 — Shop API、Merchant API、Public API 各有不同的 base string 规则
  2. 参数名前导斜杠必须保留/api/v2/xxx 不能写成 api/v2/xxx
  3. 以实际 API 响应为准 — 文档可能不准确,字段映射以真实 JSON 为准
  4. Token 管理必须有自动刷新 — 4 小时有效期很短
  5. 区分店铺类型 — 本土店和跨境店可用的 API 不同

13.2 字段映射的黄金法则

  1. 搭建测试环境 → 真实调用 → 拿到完整 JSON 响应
  2. 导出 Shopee 官方 Excel → 逐列对比 JSON 字段
  3. 确认字段类型、嵌套层级、是否可为 null
  4. 写字段映射表(已确认 + 待确认分开)
  5. 如果 API 响应与文档不一致,以响应为准

13.3 给新手的 10 条建议

  1. 回调地址只写域名 — Shopee 要求回调地址必须是纯域名,不要带路径
  2. 授权过程全量透传 — 不要分别处理不同场景的参数,全部透传给后端
  3. 查签名算法先看 Developer Guide — 花 10 分钟看 developer-guide/16 能省 3 天排查时间
  4. 参数名以文档为准order_snorder_sn_listordersn_list 是不同的东西
  5. 响应体结构看清楚response.xxx.yyy 和直接 data.yyy 是不同的
  6. 15 天限制注意 — 查询大范围数据时需分段
  7. Apifox 脚本用 URL 对象new URL() 比正则更可靠
  8. 前导斜杠不能丢 — 签名用的 path 必须以 / 开头
  9. 本土店和跨境店分开测试 — 两者的 API 可用性差异很大
  10. 前端下载名不用硬编码 — 直接使用后端返回的文件名

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 时,所有的挫折都是值得的。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值