feat: 增强 Plus Checkout 账单地址处理,支持多国地址配置及本地化国家路径
This commit is contained in:
@@ -6,13 +6,30 @@
|
||||
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
|
||||
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
|
||||
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
|
||||
const MEIGUODIZHI_PATH_BY_COUNTRY = {
|
||||
AU: '/au-address',
|
||||
DE: '/de-address',
|
||||
FR: '/fr-address',
|
||||
US: '/',
|
||||
const MEIGUODIZHI_COUNTRY_CONFIG = {
|
||||
AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] },
|
||||
AU: { path: '/au-address', city: 'Sydney', aliases: ['au', 'aus', 'australia', '澳大利亚'] },
|
||||
CA: { path: '/ca-address', city: 'Toronto', aliases: ['ca', 'canada', '加拿大'] },
|
||||
CN: { path: '/cn-address', city: 'Shanghai', aliases: ['cn', 'china', '中国'] },
|
||||
DE: { path: '/de-address', city: 'Berlin', aliases: ['de', 'deu', 'germany', 'deutschland', '德国'] },
|
||||
ES: { path: '/es-address', city: 'Madrid', aliases: ['es', 'esp', 'spain', '西班牙'] },
|
||||
FR: { path: '/fr-address', city: 'Paris', aliases: ['fr', 'fra', 'france', '法国'] },
|
||||
GB: { path: '/uk-address', city: 'London', aliases: ['gb', 'uk', 'united kingdom', 'britain', 'england', '英国'] },
|
||||
HK: { path: '/hk-address', city: 'Hong Kong', aliases: ['hk', 'hong kong', '香港'] },
|
||||
IT: { path: '/it-address', city: 'Rome', aliases: ['it', 'ita', 'italy', '意大利'] },
|
||||
JP: { path: '/jp-address', city: 'Tokyo', aliases: ['jp', 'jpn', 'japan', '日本', '日本国'] },
|
||||
KR: { path: '/kr-address', city: 'Seoul', aliases: ['kr', 'kor', 'korea', 'south korea', '韩国'] },
|
||||
MY: { path: '/my-address', city: 'Kuala Lumpur', aliases: ['my', 'malaysia', '马来西亚'] },
|
||||
NL: { path: '/nl-address', city: 'Amsterdam', aliases: ['nl', 'netherlands', 'holland', '荷兰'] },
|
||||
PH: { path: '/ph-address', city: 'Manila', aliases: ['ph', 'philippines', '菲律宾'] },
|
||||
RU: { path: '/ru-address', city: 'Moscow', aliases: ['ru', 'russia', '俄罗斯'] },
|
||||
SG: { path: '/sg-address', city: 'Singapore', aliases: ['sg', 'singapore', '新加坡'] },
|
||||
TH: { path: '/th-address', city: 'Bangkok', aliases: ['th', 'thailand', '泰国'] },
|
||||
TR: { path: '/tr-address', city: 'Istanbul', aliases: ['tr', 'turkey', 'turkiye', '土耳其'] },
|
||||
TW: { path: '/tw-address', city: 'Taipei', aliases: ['tw', 'taiwan', '台湾'] },
|
||||
US: { path: '/', city: 'New York', aliases: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'] },
|
||||
VN: { path: '/vn-address', city: 'Ho Chi Minh City', aliases: ['vn', 'vietnam', '越南'] },
|
||||
};
|
||||
const MEIGUODIZHI_SUPPORTED_COUNTRIES = new Set(Object.keys(MEIGUODIZHI_PATH_BY_COUNTRY));
|
||||
|
||||
function createPlusCheckoutBillingExecutor(deps = {}) {
|
||||
const {
|
||||
@@ -39,6 +56,27 @@
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function compactCountryText(value = '') {
|
||||
return normalizeText(value).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||||
}
|
||||
|
||||
function resolveMeiguodizhiCountryCode(value = '') {
|
||||
const normalized = normalizeText(value);
|
||||
const upper = normalized.toUpperCase();
|
||||
if (MEIGUODIZHI_COUNTRY_CONFIG[upper]) {
|
||||
return upper;
|
||||
}
|
||||
const compact = compactCountryText(normalized);
|
||||
const match = Object.entries(MEIGUODIZHI_COUNTRY_CONFIG).find(([code, config]) => (
|
||||
compact === code.toLowerCase()
|
||||
|| (config.aliases || []).some((alias) => {
|
||||
const compactAlias = compactCountryText(alias);
|
||||
return compact === compactAlias || compact.includes(compactAlias);
|
||||
})
|
||||
));
|
||||
return match?.[0] || '';
|
||||
}
|
||||
|
||||
function hasCompleteAddressFallback(seed) {
|
||||
const fallback = seed?.fallback || {};
|
||||
return Boolean(
|
||||
@@ -49,9 +87,9 @@
|
||||
}
|
||||
|
||||
function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) {
|
||||
const address1 = normalizeText(apiAddress?.Address);
|
||||
const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address);
|
||||
const city = normalizeText(apiAddress?.City);
|
||||
const region = normalizeText(apiAddress?.State || apiAddress?.State_Full);
|
||||
const region = normalizeText(apiAddress?.State_Full || apiAddress?.State);
|
||||
const postalCode = normalizeText(apiAddress?.Zip_Code);
|
||||
if (!address1 || !city || !postalCode) {
|
||||
return null;
|
||||
@@ -76,11 +114,12 @@
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
return null;
|
||||
}
|
||||
if (!MEIGUODIZHI_SUPPORTED_COUNTRIES.has(countryCode)) {
|
||||
const countryConfig = MEIGUODIZHI_COUNTRY_CONFIG[countryCode];
|
||||
if (!countryConfig?.path) {
|
||||
return null;
|
||||
}
|
||||
const path = MEIGUODIZHI_PATH_BY_COUNTRY[countryCode] || MEIGUODIZHI_PATH_BY_COUNTRY.DE;
|
||||
const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || '');
|
||||
const path = countryConfig.path;
|
||||
const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || countryConfig.city);
|
||||
const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -102,18 +141,43 @@
|
||||
return buildDirectAddressSeed(countryCode, data.address || {}, fallbackSeed);
|
||||
}
|
||||
|
||||
async function resolveBillingAddressSeed(state = {}, countryOverride = '') {
|
||||
const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE');
|
||||
const fallbackSeed = getAddressSeedForCountry(requestedCountry, {
|
||||
function getLocalAddressSeed(countryCode) {
|
||||
if (typeof getAddressSeedForCountry !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const seed = getAddressSeedForCountry(countryCode, {
|
||||
fallbackCountry: 'DE',
|
||||
});
|
||||
if (!fallbackSeed) {
|
||||
throw new Error('步骤 7:未找到可用的本地账单地址种子。');
|
||||
}
|
||||
return seed?.countryCode === countryCode ? seed : null;
|
||||
}
|
||||
|
||||
const countryCode = fallbackSeed.countryCode || 'DE';
|
||||
function buildMeiguodizhiLookupSeed(countryCode) {
|
||||
const config = MEIGUODIZHI_COUNTRY_CONFIG[countryCode];
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
countryCode,
|
||||
query: config.city,
|
||||
fallback: {
|
||||
address1: '',
|
||||
city: config.city,
|
||||
region: '',
|
||||
postalCode: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBillingAddressSeed(state = {}, countryOverride = '') {
|
||||
const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE');
|
||||
const countryCode = resolveMeiguodizhiCountryCode(requestedCountry) || 'DE';
|
||||
const localSeed = getLocalAddressSeed(countryCode);
|
||||
const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode);
|
||||
if (!lookupSeed) {
|
||||
throw new Error(`步骤 7:无法识别账单国家或地区:${requestedCountry || '空'}`);
|
||||
}
|
||||
try {
|
||||
const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed);
|
||||
const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, lookupSeed);
|
||||
if (hasCompleteAddressFallback(remoteSeed)) {
|
||||
await addLog(
|
||||
`步骤 7:已从 meiguodizhi 接口获取账单地址(${remoteSeed.fallback.city} / ${remoteSeed.fallback.postalCode}),将跳过 Google 地址推荐。`,
|
||||
@@ -126,7 +190,10 @@
|
||||
await addLog(`步骤 7:meiguodizhi 地址接口不可用,回退到本地地址种子:${error?.message || String(error || '')}`, 'warn');
|
||||
}
|
||||
|
||||
return fallbackSeed;
|
||||
if (hasCompleteAddressFallback(localSeed)) {
|
||||
return localSeed;
|
||||
}
|
||||
throw new Error(`步骤 7:${requestedCountry} 的 meiguodizhi 地址不可用,且没有本地兜底地址。`);
|
||||
}
|
||||
|
||||
async function getAlivePlusCheckoutTabId(tabId) {
|
||||
|
||||
@@ -689,11 +689,28 @@ function getCountryCandidates(value = '') {
|
||||
const raw = normalizeText(value);
|
||||
const compact = raw.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||||
const aliases = {
|
||||
AR: ['Argentina', '阿根廷'],
|
||||
AU: ['Australia', '澳大利亚'],
|
||||
CA: ['Canada', '加拿大'],
|
||||
CN: ['China', '中国'],
|
||||
DE: ['Germany', 'Deutschland', '德国'],
|
||||
ES: ['Spain', '西班牙'],
|
||||
FR: ['France', '法国'],
|
||||
GB: ['United Kingdom', 'UK', 'Britain', 'England', '英国'],
|
||||
HK: ['Hong Kong', '香港'],
|
||||
IT: ['Italy', '意大利'],
|
||||
JP: ['Japan', '日本', '日本国'],
|
||||
KR: ['Korea', 'South Korea', '韩国'],
|
||||
MY: ['Malaysia', '马来西亚'],
|
||||
NL: ['Netherlands', 'Holland', '荷兰'],
|
||||
PH: ['Philippines', '菲律宾'],
|
||||
RU: ['Russia', '俄罗斯'],
|
||||
SG: ['Singapore', '新加坡'],
|
||||
TH: ['Thailand', '泰国'],
|
||||
TR: ['Turkey', 'Turkiye', '土耳其'],
|
||||
TW: ['Taiwan', '台湾'],
|
||||
US: ['United States', 'United States of America', 'USA', '美国'],
|
||||
VN: ['Vietnam', '越南'],
|
||||
};
|
||||
const direct = aliases[String(raw || '').trim().toUpperCase()] || [];
|
||||
const matched = Object.entries(aliases).find(([code, names]) => {
|
||||
|
||||
@@ -344,7 +344,7 @@ test('Plus checkout billing uses the detected checkout country before choosing a
|
||||
await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' });
|
||||
|
||||
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
|
||||
assert.equal(requestedCountries[0], 'Australia');
|
||||
assert.equal(requestedCountries[0], 'AU');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales');
|
||||
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
|
||||
@@ -354,6 +354,56 @@ test('Plus checkout billing uses the detected checkout country before choosing a
|
||||
});
|
||||
});
|
||||
|
||||
test('Plus checkout billing uses meiguodizhi country paths for localized countries without local seeds', async () => {
|
||||
const fetchRequests = [];
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
|
||||
8: {
|
||||
hasPayPal: false,
|
||||
paypalCandidates: [],
|
||||
billingFieldsVisible: true,
|
||||
countryText: '日本',
|
||||
},
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
fetchRequests.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
address: {
|
||||
Address: 'トウキョウト, ミナトク, シバダイモン, 10-4',
|
||||
Trans_Address: '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo',
|
||||
City: 'Tokyo',
|
||||
State: 'Tokyo',
|
||||
Zip_Code: '105-0012',
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' });
|
||||
|
||||
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'JP');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, '10-4, Shiba Daimon 2-chome, Minato-ku, Tokyo');
|
||||
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
|
||||
city: 'Tokyo',
|
||||
path: '/jp-address',
|
||||
method: 'refresh',
|
||||
});
|
||||
});
|
||||
|
||||
test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => {
|
||||
const { executor } = createExecutorHarness({
|
||||
frames: [
|
||||
|
||||
Reference in New Issue
Block a user