Files
eSIM-Tools/netlify/functions/giffgaff-graphql.js
Abner 33b8e20440 ♻️ refactor: 抽取内部请求头构建并规范 Cookie 验证返回语义
- 新增共享模块 netlify/functions/_shared/internal-headers.js,集中构建内部函数互调请求头,统一 ACCESS_KEY 未配置时的 500 抛错逻辑
- 更新 giffgaff-graphql.js / giffgaff-mfa-challenge.js / giffgaff-mfa-validation.js,移除各自重复的 getInternalHeaders 实现,改为复用 getInternalHeaders
- 调整 verify-cookie.js 返回语义:以 success 表示 HTTP 层请求是否成功、以 valid 表示是否具备可用于后续 API 的 JWT 令牌
- 修正 verify-cookie.js 错误提示文案中的乱码问题,确保提示“无法通过Cookie获取访问令牌”表述正确
2026-05-31 20:30:31 +08:00

221 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Netlify Function: Giffgaff GraphQL API
* 处理GraphQL请求,解决CORS问题
*/
const axios = require('axios');
const { withAuth, validateInput, AuthError } = require('./_shared/middleware');
const { getInternalHeaders } = require('./_shared/internal-headers');
// 输入验证schema
const graphqlSchema = {
query: {
required: true,
type: 'string',
minLength: 10
},
accessToken: {
required: false,
type: 'string',
minLength: 50
}
};
exports.handler = withAuth(async (event, context, { auth, body }) => {
const ts = new Date().toISOString();
console.log(`[GGQL] ${ts} | invoked, operationName=${body.operationName || 'none'}, hasAccessToken=${!!body.accessToken}, hasMfaSignature=${!!body.mfaSignature}, hasCookie=${!!body.cookie}`);
// 解析请求体
const { mfaSignature, mfaRef, query, variables, operationName, cookie } = body;
// 从请求体或 Authorization 头提取 accessToken兼容两种方式
const lowerCaseHeaders = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [String(k).toLowerCase(), v])
);
const authHeader = lowerCaseHeaders['authorization'] || '';
const headerMfaRefRaw = lowerCaseHeaders['x-mfa-ref'] || lowerCaseHeaders['x-mfa-challenge-ref'] || '';
const headerMfaRef = typeof headerMfaRefRaw === 'string' ? headerMfaRefRaw.trim() : '';
const bodyMfaRef = typeof mfaRef === 'string' ? mfaRef.trim() : '';
const resolvedMfaRef = bodyMfaRef || headerMfaRef;
let accessToken = body.accessToken;
if (!accessToken && authHeader.startsWith('Bearer ')) {
accessToken = authHeader.slice(7);
}
if (!accessToken) {
throw new AuthError('accessToken是必需的', 400);
}
if (!query) {
throw new AuthError('GraphQL query是必需的', 400);
}
// 构建请求头(使用真实的 iOS App UA 和头部)
const requestHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'Accept': '*/*',
'User-Agent': process.env.GG_USER_AGENT || 'giffgaff/1332 CFNetwork/1568.300.101 Darwin/24.2.0',
'Origin': 'https://publicapi.giffgaff.com',
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
'Accept-Encoding': 'gzip, deflate, br'
};
// 针对 reserveESim / swapSim / eSimDownloadToken 需要设备元数据头
const opName = operationName || '';
const isReserve = /reserveESim\s*\(/.test(String(query || '')) || /reserveESim/i.test(opName);
const isSwap = /swapSim\s*\(/i.test(String(query || '')) || /swapSim/i.test(opName);
const isToken = /eSimDownloadToken\s*\(/i.test(String(query || '')) || /eSimDownloadToken/i.test(opName);
const isMfaChallenge = /simSwapMfaChallenge/i.test(String(query || '')) || /simSwapMfaChallenge/i.test(opName);
const isESims = /eSims\s*\(/i.test(String(query || '')) || /getESims/i.test(opName);
const needsAppHeaders = isReserve || isSwap || isToken || isMfaChallenge || isESims;
if (needsAppHeaders) {
requestHeaders['x-gg-app-device-manufacturer'] = process.env.GG_APP_DEVICE_MANUFACTURER || 'Apple';
requestHeaders['x-gg-app-os'] = process.env.GG_APP_OS || 'iOS';
requestHeaders['x-gg-app-version'] = process.env.GG_APP_VERSION || '17.46.11';
requestHeaders['x-gg-app-build-number'] = process.env.GG_APP_BUILD_NUMBER || '1332';
requestHeaders['x-gg-app-os-version'] = process.env.GG_APP_OS_VERSION || '18.2';
requestHeaders['apollographql-client-name'] = process.env.APOLLO_CLIENT_NAME || 'iOS 18.2';
requestHeaders['apollographql-client-version'] = process.env.APOLLO_CLIENT_VERSION || '17.46.11 1332';
requestHeaders['x-gg-app-bundle-version'] = process.env.GG_APP_BUNDLE_VERSION || 'v0';
requestHeaders['x-gg-app-device-model'] = process.env.GG_APP_DEVICE_MODEL || 'iPhone SE';
requestHeaders['x-gg-app-device-id'] = process.env.GG_APP_DEVICE_ID || 'iPhone12,8';
requestHeaders['baggage'] = process.env.GG_BAGGAGE || 'client-tracking-ctx-id=d1c9ee72-573b-490e-a219-6b41992a5bdb';
try {
const { randomUUID } = require('crypto');
requestHeaders['x-request-id'] = randomUUID();
} catch (_) {}
}
// 如果有MFA签名添加到请求头
if (mfaSignature) {
requestHeaders['X-MFA-Signature'] = mfaSignature;
requestHeaders['x-mfa-signature'] = mfaSignature;
}
if (isSwap && resolvedMfaRef) {
// 上游 swapSim 对 MFA challenge ref 兼容性要求较严格,显式双写头部
requestHeaders['X-GG-MFA-REF'] = resolvedMfaRef;
requestHeaders['x-gg-mfa-ref'] = resolvedMfaRef;
}
// 构建GraphQL请求体
const graphqlBody = {
query,
variables: variables || {},
operationName: operationName || null
};
// swapSim 需要 mfaRef 作为顶层字段Giffgaff schema 要求)
if (resolvedMfaRef) {
graphqlBody.mfaRef = resolvedMfaRef;
}
if (isSwap && !resolvedMfaRef) {
throw new AuthError('swapSim 缺少 mfaRefMFA challenge reference', 400);
}
if (isSwap) {
graphqlBody.variables = {
...(graphqlBody.variables || {}),
mfaRef: resolvedMfaRef
};
}
// 供失败时刷新令牌使用的 verify-cookie 地址
const hostHdr = lowerCaseHeaders['x-forwarded-host'] || lowerCaseHeaders['host'] || '';
const protoHdr = lowerCaseHeaders['x-forwarded-proto'] || 'https';
const verifyCookieUrl = hostHdr ? `${protoHdr}://${hostHdr}/.netlify/functions/verify-cookie` : ((process.env.URL || '').replace(/\/$/, '') + '/.netlify/functions/verify-cookie');
// 调用Giffgaff GraphQL API
console.log(`[GGQL] ${ts} | calling upstream: op=${opName}, isSwap=${isSwap}, hasMfaSignature=${!!mfaSignature}, hasResolvedMfaRef=${!!resolvedMfaRef}`);
// swapSim 操作在遇到上游 500 错误时自动重试(最多 2 次)
const maxRetries = isSwap ? 2 : 0;
let response;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delayMs = attempt * 1500;
console.log(`[GGQL] ${ts} | retry #${attempt} for ${opName} after ${delayMs}ms`);
await new Promise(r => setTimeout(r, delayMs));
}
try {
response = await axios.post(
'https://publicapi.giffgaff.com/gateway/graphql',
graphqlBody,
{ headers: requestHeaders, timeout: 30000 }
);
console.log(`[GGQL] ${ts} | upstream OK: status=${response.status}, hasData=${!!response.data}, hasErrors=${!!response.data?.errors}`);
// Giffgaff 返回 HTTP 200 但 GraphQL body 包含 errors 时,视为业务失败
if (response.data?.errors?.length > 0) {
const gqlErr = response.data.errors[0];
const gqlMsg = gqlErr?.message || gqlErr?.error || JSON.stringify(gqlErr);
console.error(`[GGQL] ${ts} | GraphQL errors from upstream: ${gqlMsg}`);
// 区分上游服务器错误500和业务校验错误
const isUpstream500 = /500|internal.?server.?error/i.test(gqlMsg);
if (isUpstream500 && attempt < maxRetries) {
console.warn(`[GGQL] ${ts} | upstream 500 on attempt #${attempt}, will retry`);
continue; // 重试
}
const errorType = isUpstream500 ? '上游服务器错误' : '业务校验错误';
throw new AuthError(`Upstream GraphQL ${errorType}: ${gqlMsg}`, isUpstream500 ? 502 : 422);
}
// 成功,跳出重试循环
break;
} catch (err) {
// AuthError含上游 500已在上方抛出直接向上传播
if (err instanceof AuthError) throw err;
const status = err.response?.status;
const data = err.response?.data || {};
const isUnauthorized = status === 401 || data?.error === 'unauthorized' || /invalid_token/i.test(String(data?.error || ''));
console.error(`[GGQL] ${ts} | upstream FAILED: status=${status}, isUnauthorized=${isUnauthorized}, errMsg=${err.message}`);
// 失败 401 时尝试用 cookie 刷新后重试一次
if (isUnauthorized && cookie) {
console.log(`[GGQL] ${ts} | attempting cookie-based token refresh`);
try {
const r = await axios.post(verifyCookieUrl, { cookie }, {
headers: getInternalHeaders(),
timeout: 15000
});
if (r.data?.valid && r.data?.accessToken) {
accessToken = r.data.accessToken;
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
console.log(`[GGQL] ${ts} | token refreshed, retrying upstream call`);
response = await axios.post(
'https://publicapi.giffgaff.com/gateway/graphql',
graphqlBody,
{ headers: requestHeaders, timeout: 30000 }
);
console.log(`[GGQL] ${ts} | retry OK: status=${response.status}`);
break; // 重试成功,跳出循环
} else {
console.error(`[GGQL] ${ts} | cookie refresh failed: valid=${r.data?.valid}`);
throw err;
}
} catch (reErr) {
console.error(`[GGQL] ${ts} | token refresh/retry FAILED: ${reErr.message}`);
throw new AuthError('Access token expired. Please re-login with cookie.', 401);
}
} else {
throw err;
}
}
}
return {
statusCode: 200,
body: JSON.stringify(response.data)
};
}, { validateSchema: graphqlSchema });