简介:面向需要在自有应用中集成星巴克订购能力的开发者,这一 9KB 压缩包提供了对星巴克私有订购接口的 JavaScript 封装。资源共 9 个文件,以 3 个脚本文件为核心,覆盖接口请求、签名计算与指纹采集等关键环节;另附说明文档、依赖锁定文件、许可证、配置文件与模板,便于理解认证机制、数据结构和二次开发方式。已有 1377 人学习浏览,适合研究私有接口封装、熟悉网络请求和异步编程的初中级前端开发者。通过阅读并运行这些脚本,可以掌握商品信息获取、订单构建、支付处理等典型调用流程,也能学习到 REST 风格接口对接、JSON 数据解析、跨域处理、安全凭据保护和错误捕获等实战要点。包体仅 9KB,结构紧凑,适合作为轻量级参考实现,帮助读者在自有项目中快速搭建星巴克订购功能原型。
1. 私有订购API的JavaScript接口:它能做什么,适合谁
绿色圆形Logo的咖啡品牌移动App背后,藏着一套不公开的私有订购API。平时它只被官方客户端调用,但项目角度完全可以把它逆向梳理出来,再用JavaScript做成接口层:查门店、拉菜单、加购物车、确认订单,全部变成可供脚本调用的函数。这个项目的目标,就是把私有订购API封装成JavaScript接口,让那个只能点单的App变成可编程的自动化能力。
适合谁:做自动化回归测试的人,想把点单流程变成CI里的一个环节;做聚合餐饮平台的人,想给自建系统接上真实下单能力;研究接口安全和风控的人,想观察私有API的鉴权与限流机制。不适合谁:打算拿它直接对外商用的团队。私有API没有服务承诺,对方改一个字段名就能让你的封装全面翻车,抱着一次性封装长期躺赢想法的人,建议先看第五章再决定。
2. 私有API从哪里来:抓包、鉴权与端点的梳理
想封装私有API,第一件事不是写代码,而是拿到一份可信的接口样本。官方不会有文档,常见做法是自己在研究环境里抓一次完整的订购链路,把请求记录保存下来,再逐条筛选。这一步决定了后续所有代码的可靠性,抓包不干净,后面全是废代码。
2.1 抓包之前想清楚三件事
准备一台研究专用手机,装好目标App并登录一个测试账号,不要用日常主账号。然后把手机WiFi流量指向桌面抓包工具,安装并信任调试证书,这样HTTPS请求才能被解开查看。最后手动走一遍完整链路:查门店、看菜单、加购物车、进入结算页再退出,全程不要切换账号。结束后把抓包结果导出成har文件备用。
拿到har后,不要直接在工具里肉眼翻,写个脚本把JSON请求过滤出来更高效:
const fs = require('fs'); const path = process.argv[2]; // 用法: node filter-har.js capture.har const har = JSON.parse(fs.readFileSync(path, 'utf8')); const rows = []; for (const entry of har.log.entries) { const { method, url, status } = entry.request; const contentType = entry.response.content.mimeType; if (!contentType.includes('json')) continue; // 只保留 JSON 响应 rows.push({ method, status, url, size: entry.response.content.size }); } console.table(rows.sort((a, b) => a.url.length - b.url.length));逻辑说明:过滤掉图片、埋点、日志上传等非JSON响应,只留返回JSON的接口,再按URL长度排序,方便把短路径的API端点挑出来。参数说明:capture.har是抓包工具导出的文件名,路径作为命令行参数传入;mimeType为application/json的请求才是我们要看的业务接口。筛选时按域名分组,重点看与你账号相关的业务域名,忽略统计类和广告类的请求。
2.2 鉴权链路里最容易忽略的三个机制
私有API的鉴权绝大多数是OAuth2的变种,三个东西决定你能不能活过十分钟:access_token、refresh_token、设备指纹头。access_token有效期通常很短,有的只有十分钟;refresh_token用来换新的access_token,有效期以天计算;设备指纹一般放在X-Device-Id这类自定义头里,服务端拿它绑定token。很多人只处理了前两个,第三个漏掉,结果就是换个环境立刻401。
写代码时我习惯先把token管理单独抽出来,做成一个能自动刷新的类:
class TokenManager { constructor({ baseURL, refreshToken, deviceId, fetchImpl = fetch }) { this.baseURL = baseURL; this.refreshToken = refreshToken; this.deviceId = deviceId; this.fetchImpl = fetchImpl; this.accessToken = ''; this.expiresAt = 0; } async getToken(force = false) { const safeWindow = 60 * 1000; // 提前 60 秒刷新,避免边界竞争 if (!force && Date.now() < this.expiresAt - safeWindow) { return this.accessToken; } return this.#refreshTokens(); } async #refreshTokens() { const url = `${this.baseURL}/v1/auth/token/refresh`; const res = await this.fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Device-Id': this.deviceId, // 设备指纹必须与抓包时一致 }, body: JSON.stringify({ refresh_token: this.refreshToken }), }); if (!res.ok) throw new Error(`refresh failed: ${res.status}`); const data = await res.json(); this.accessToken = data.access_token; this.expiresAt = Date.now() + data.expires_in * 1000; return this.accessToken; } }逻辑说明:getToken先判断token是否在安全期内,不满足就刷新。刷新接口的请求头里必须带设备指纹,否则服务端会认为这是另一台设备发起的请求,直接拒绝。参数说明:safeWindow是提前量,设60秒是因为私有API的时钟偏差和网络延迟可能让边界处的请求刚好过期;refresh_token需要从抓包记录里手动提取,第一次是登录接口下发的。设备指纹这事我说它是玄学,因为格式、大小写都敏感,抓包看到的是什么样,代码里就得发送什么样,差一个字符都救不回来。
2.3 整理端点草表:把几百个请求浓缩成一张表
一次全流程抓包下来,少说几十个JSON请求,其中一半是埋点。我一般按业务模块把它们分组,整理成一张端点草表,后续写代码只看这张表,不再翻原始记录:
| 业务 | 方法 | 路径示例 | 鉴权 | 关键参数 |
|---|---|---|---|---|
| 查门店 | GET | /v1/stores | Bearer | lat, lng, radius |
| 拉菜单 | GET | /v1/menus/{storeId} | Bearer | storeId, categoryId |
| 加购物车 | POST | /v1/carts/items | Bearer | beverageId, size, qty |
| 确认订单 | POST | /v1/orders | Bearer | cartId, paymentId, pickupType |
这些路径是我做的演示占位符,真实路径以你抓到的为主。整理时注意几个规律:查询类接口几乎都是GET,返回体大且字段多;写操作类接口是POST,响应体反而很小,往往只回一个id或状态码。最容易搞混的是路径里有两种ID:门店ID和商品ID,后续所有请求的参数都围绕这两类ID展开。草表整理好后,拿一个GET端点做最小探测,确认token和设备头组合能通,再进入接口层封装。
3. 封装成JS接口层:请求器、客户端与类型定义
端点草表是地图,JS接口层才是交通工具。这一层我习惯拆成三个文件:一个请求器负责网络细节,一个业务客户端负责把端点变成函数,一份类型定义负责把接口的坑写在注释里。三者分开后,接口改版时只改一处,其他代码不动。
3.1 请求器:统一解决超时、重试与鉴权头注入
每个接口都要带鉴权头、设备指纹、超时控制,如果散落在各个业务方法里,后面改一处要动几十处。所以我先写一个ApiClient,把所有网络细节收在request方法里:
class ApiClient { constructor({ baseURL, tokenManager, timeout = 8000, fetchImpl = fetch }) { this.baseURL = baseURL; this.tokenManager = tokenManager; this.timeout = timeout; this.fetchImpl = fetchImpl; } async request(path, { method = 'GET', params, body, retryable = true } = {}) { const url = new URL(this.baseURL + path); if (params) { Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); } const headers = { Authorization: `Bearer ${await this.tokenManager.getToken()}`, 'X-Device-Id': this.tokenManager.deviceId, 'Content-Type': 'application/json', }; let lastError; const maxRetries = retryable ? 2 : 0; for (let attempt = 0; attempt <= maxRetries; attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeout); try { const res = await this.fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (res.status === 401) { await this.tokenManager.getToken(true); // 强制刷新后重试一次 lastError = new Error('token expired'); continue; } if (!res.ok) { throw new Error(`HTTP ${res.status}: ${await res.text()}`); } return await res.json(); } catch (err) { lastError = err; if (attempt < maxRetries && retryable) { await new Promise(r => setTimeout(r, 200 * 2 ** attempt)); } } finally { clearTimeout(timer); } } throw lastError; } }逻辑说明:每次请求先取token拼进Authorization头,再带上设备指纹。超时用AbortController实现,超过8秒直接中断;遇到401会强制刷新token后重试一次,这是为了处理token在请求发出前刚过期的边界情况。参数说明:retryable默认true,只对GET类幂等请求开启;POST下单这类操作必须传retryable: false,否则网络抖动时自动重试可能造成重复扣款或重复下单。重试间隔用指数退避,200毫秒、400毫秒,最多2次,避免把服务端惹毛。
3.2 业务客户端:把端点草表变成可调用方法
有了请求器,业务客户端就是把端点和参数组合成语义化函数。这样上层业务代码永远不接触URL和header,只关心函数名和参数:
class CoffeeApiClient { constructor(apiClient) { this.api = apiClient; } async getStores({ lat, lng, radius = 2000 }) { return this.api.request('/v1/stores', { params: { lat, lng, radius } }); } async getMenu(storeId) { return this.api.request(`/v1/menus/${storeId}`); } async addCartItem({ storeId, beverageId, size, qty = 1, note }) { return this.api.request('/v1/carts/items', { method: 'POST', body: { storeId, beverageId, size, qty, note }, retryable: false, // 写操作绝不自动重试 }); } async confirmOrder({ cartId, paymentId, pickupType }) { return this.api.request('/v1/orders', { method: 'POST', body: { cartId, paymentId, pickupType }, retryable: false, }); } }逻辑说明:每个方法对应草表里的一行,路径、方法、重试策略全在这里定死。getStores没传radius时有默认值2000米,其他参数必须显式传入,避免漏参导致接口报错。说明一点:私有API的业务错误经常不体现在HTTP状态码上,而是返回200但响应体里带result.code: 40003这类业务码。我习惯在request返回前加一层归一化判断,把业务码映射成本地异常,否则上层代码拿到200就会误以为成功了。
3.3 类型定义:用JSDoc把接口的坑锁在编辑器里
私有API的字段名很随意,同一个字段在不同接口里可能叫storeId、store_id、StoreId。我不用TypeScript,因为私有API改版频繁,引入编译链成本高;用JSDoc加编辑器提示就够了:
/** * @typedef {Object} Store * @property {string} id 门店id * @property {string} name 门店名 * @property {number} lat 纬度 * @property {number} lng 经度 * @property {boolean} isOpen 是否营业 * @property {number} distanceMeters 距离,单位米 */ /** * @typedef {Object} MenuItem * @property {string} id 商品id * @property {string} name 商品名 * @property {number} basePrice 基础价格,单位分 * @property {Object<string, number>} variants 杯型价格表,如 { tall: 3000 } * @property {'sold_out'|'in_stock'|'customizable'} availability 库存状态 */逻辑说明:basePrice单位我刻意写成“分”,因为私有API的价格字段经常用整数表示最小货币单位,如果用元,小数运算会产生精度问题。availability用字符串枚举而不是布尔值,因为实际接口里还有customizable这种中间态,布尔表达不了三层语义。类型定义解决的是调用方的拼写问题,但运行时返回的数据还得靠归一化函数双保险。
4. 跑通订购流程:门店、菜单、下单三段的代码实践
接口层搭好后,核心流程就是三段:选门店、选商品、下单确认。顺序不能乱,因为加购物车必须绑定门店ID,确认订单必须绑定购物车ID,跳步会直接报业务错误。下面按实际调用顺序拆开讲。
4.1 门店查询:经纬度半径与距离排序
私有API的门店接口一般要求经纬度而不是城市名,这是因为它直接对接地图服务。但抓包时你看到的是App计算好的距离,实际传参只有经纬度和半径。我拿到门店列表后,不会轻信接口返回的distanceMeters字段,而是自己算一遍距离再排序:
function calcDistance(lat1, lng1, lat2, lng2) { const R = 6371000; // 地球半径,单位米 const toRad = d => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))); } async function pickNearestStore(client, { lat, lng }) { const payload = await client.getStores({ lat, lng, radius: 5000 }); const stores = payload.stores || payload.list || []; const sorted = stores .map(s => ({ ...s, distance: calcDistance(lat, lng, s.lat, s.lng) })) .sort((a, b) => a.distance - b.distance); return sorted[0] ?? null; }逻辑说明:calcDistance是Haversine公式,用经纬度算球面距离,返回米。payload.stores || payload.list是兼容接口字段名不一致的写法,私有API同一个业务在不同版本可能改过字段名。参数说明:radius传5000米是实测经验,传太大会让部分接口返回空数组或触发风控,传太小又可能漏掉最近的门店。注意门店接口返回的经纬度可能是字符串,先Number()再传入计算函数,否则求差会得到字符串拼接的错误结果。
4.2 菜单拉取:把扁平列表组装成可点选的树
菜单接口返回的通常是一个扁平的菜品列表,每个商品带parentId字段,用它就能还原树形分类。直接拿扁平列表渲染没法做分类筛选,先组装成树更合理:
function buildMenuTree(items) { const map = new Map(items.map(it => [it.id, { ...it, children: [] }])); const roots = []; for (const node of map.values()) { if (node.parentId && map.has(node.parentId)) { map.get(node.parentId).children.push(node); } else { roots.push(node); } } return roots; }逻辑说明:第一遍遍历把所有节点塞进Map,第二遍遍历根据parentId挂到父节点下,没有parentId的作为根分类。这里的parentId是商品分类的层级关系,不是商品本身。组装完后,价格还要单独处理:同一种饮品会因杯型不同有不同价格,接口里可能叫sizePriceMap,也可能叫variants,而且价格单位可能是分、可能是元。我建议写一个resolvePrice(item, size)函数,内部先查变体表,查不到再回退basePrice,单位统一转成“分”。
库存过滤也很关键。很多接口的售罄字段是availability: "sold_out",但有的版本叫isAvailable: false,还有的把售罄商品直接不返回。我一般同时判断两个条件:item.availability === 'sold_out' || item.isAvailable === false就过滤掉,并打印日志确认命中逻辑是对的,避免悄悄把上架商品也滤掉。
4.3 下单三段式:加购、确认、提交,不许跳步
下单接口不能像查菜单那样一把梭,全程是状态机:先逐项加购,拿到购物车ID,再确认订单。跳步的后果是接口返回INVALID_ORDER_STATE,前面加购的购物车可能直接失效。完整流程写成函数就是这个样子:
async function placeOrder(client, { storeId, items, paymentId }) { const cart = []; for (const it of items) { const res = await client.addCartItem({ storeId, ...it }); cart.push(res.cartId || res.itemId); } const cartId = resolveCartId(cart); // 从最后一次加购响应里取 cartId return client.confirmOrder({ cartId, paymentId, pickupType: 'store' }); }逻辑说明:循环加购每一项商品,加购响应里一般会返回购物车ID或商品行ID,取最后一次响应的cartId作为下单参数。参数说明:pickupType传'store'表示到店自取,外送场景传'delivery',但外送涉及地址和运费计算,第一阶段先不要碰。提示:确认订单接口提交后不要重复调用,很多私有API不做幂等,重复提交会产生两笔订单。测试时支付方式用测试环境的沙箱支付ID,绝对不要传真实支付方式,不然你测试一次就真的付一次款。
订单响应里的状态字段也容易踩坑,常见值是ORDER_STATUS: "SUCCESS"或"PROCESSING"。我习惯在封装里把状态映射成本地枚举,SUCCESS映射成confirmed,PROCESSING映射成pending,然后定义isFinal方法判断是否终结态,避免上层业务用===直接比字符串,比错一个大小写就翻车。
5. 私有API避坑:七个高频坑与排查路径
私有API没有文档,所有边界都是试出来的。下面这些坑是我自己和几个做同类研究的朋友共同踩出来的血泪经验。每条按现象、原因、解决方式写,排查时直接对照。
5.1 Token 相关的三个高频坑
坑一:token明明没过期却被401。现象是本地expiresAt还有半个小时,请求却返回401。原因多半不是token过期,而是设备指纹或User-Agent变了,服务端判定token被别的设备使用,立即吊销。解决方式:把抓包时看到的请求头完整保存下来,UA、Accept-Language、X-Device-Id一个都不能少,代码里全部原样带上。
坑二:refresh token隔几天再用就失效。现象是刷新接口返回403,refresh token已经被吊销。原因是多数私有API对refresh token设了闲置超时,几天不用就作废。解决方式:写一个定时心跳,每小时调用一次低开销接口,比如查门店的GET请求,让服务端认为会话仍然活跃。
坑三:多个请求并发时同时触发刷新,导致第二个刷新请求被限流。现象是日志里连续出现多个refresh请求,后面几个429。解决方式:在TokenManager里加一个共享Promise,刷新期间并发的调用都等待同一个刷新结果:
let inflight = null; async function refreshShared() { if (!inflight) { inflight = this.#refreshTokens().finally(() => (inflight = null)); } return inflight; }逻辑说明:inflight保存进行中的刷新Promise,后续调用直接复用它,刷新完成后重置为null。这样就避免了并发刷新。参数说明:这个方案不改之前的getToken接口,只把#refreshTokens的调用入口换成refreshShared即可。
5.2 数据解析与业务语义的四个坑
坑四:金额单位飘忽不定。现象是同一杯咖啡,A接口返回30,B接口返回30.00,另一个接口返回3000。原因是有的用元、有的用分。解决方式:在类型定义层统一转成“分”存整数,所有入参出参只用number,不做字符串运算。
坑五:时间戳是带空格的字符串。现象是new Date("2025-06-01 10:00:00")在部分运行环境返回Invalid Date。解决方式:先替换空格为T再解析,即new Date(str.replace(' ', 'T')),这样能兼容完整ISO格式。
坑六:字段名大小写混用。现象是storeId能取到值,store_id取不到,换一个接口又反过来。解决方式:写一个pick(obj, keys)归一化函数,从候选key列表里取第一个有值的返回,把所有响应统一成camelCase后再交给上层。
坑七:订单状态里有隐藏中间态。现象是只处理了成功和失败,结果发现订单卡在“制作中”没有被任何分支覆盖。原因是接口里有PROCESSING、WAIT_PICKUP等中间状态。解决方式:把抓包记录里出现过的所有状态值全部枚举出来,映射到本地状态机,并给WAIT_PICKUP这类状态加超时重查逻辑。
5.3 被风控拦截时的排查清单
私有API的风控像黑匣子,不会告诉你为什么被封。被拦时我按清单逐项排查:请求频率是否比官方App快,官方点一下等两秒,脚本如果是疯狂并发,先降频;请求头是否少了Accept-Language或Accept-Encoding;设备指纹是否每次运行都重新生成,有的设备ID生成库每次跑会产出新值;调试证书是否过期,证书失效后HTTPS解包会直接失败。
排查时给请求器加一层日志包装,把每次请求的路径、耗时、状态码落到本地文件,方便和抓包记录对比:
function withLog(client) { const orig = client.request.bind(client); client.request = async (...args) => { const t = Date.now(); const data = await orig(...args); console.log(args[0], `${Date.now() - t}ms`); return data; }; return client; }逻辑说明:withLog在request外层包了一层计时器,每个请求打印路径和耗时。参数说明:这个包装只应用于开发调试,上线前要移除,否则每个请求都会刷屏。风控排查的核心原则是贴近官方行为,官方怎么请求,你就怎么请求,任何“更高效”的请求方式都是在给自己找封禁。
6. 验证与进阶:录制回放是私有接口封装最好的后悔药
6.1 30秒冒烟测试:让封装自检
改完代码后,我最先跑的不是完整下单,而是一个30秒能跑完的冒烟脚本。它只验证最核心的链路没有断:
async function smoke(client) { const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; const store = await pickNearestStore(client, { lat: 10.0, lng: 20.0 }); // 替换为你的测试坐标 assert(store, 'no store returned'); const menu = await client.getMenu(store.id); assert(menu.length > 0, 'empty menu'); console.log('smoke ok'); }逻辑说明:断言分别校验门店非空、菜单非空,任何一个失败都抛异常。参数说明:坐标写成占位符,跑之前替换成你测试地点的真实经纬度。断言只校验数据是否存在,不校验具体值,因为值每天都在变。
6.2 录制回放:把一次成功调用变成离线回归用例
私有API改版后,你可能连不上服务,线上也无法复现。我的做法是把一次成功的请求响应保存成快照,之后离线也能回归:
async function saveSnapshot(name, data) { require('fs').writeFileSync(`snapshots/${name}.json`, JSON.stringify(data, null, 2)); } async function loadSnapshot(name) { return JSON.parse(require('fs').readFileSync(`snapshots/${name}.json`, 'utf8')); }逻辑说明:快照保存的是服务端原始响应,回放时不再发网络请求,直接用之前的JSON跑解析逻辑。参数说明:保存前要对快照做脱敏,把个人信息字段替换成占位符,防止敏感信息落地到测试仓库里。
6.3 进阶:慢速重放与灰度开关
最后给接口层留一个慢速开关:接入CI或灰度验证时,把请求速度降到官方App的三分之一,观察封装在弱网和限流边缘的表现:
async function requestWithLatency(fn, ms = 0) { if (ms > 0) await new Promise(r => setTimeout(r, ms)); return fn(); }逻辑说明:requestWithLatency在调用前强制等待指定毫秒数,模拟慢速操作。参数说明:ms默认0表示不降速,灰度时传300到500毫秒。我现在的习惯是每次跑完整流程前,先开着日志和慢速开关走一遍下单链路,确认每一步状态码符合预期,再关掉这些调试代码跑正式流程。这个习惯救过我很多次,尤其是确认订单那种不可重试的接口,提前发现问题比事后找后悔药强。希望帮到你。
本文还有配套的精品资源,点击获取