Appearance
出海 App 怎么按 IP 归属地做多币种地区定价?JavaScript 实现
出海 App 上线第一周最常见的吐槽是:用户在美国打开商品页,价格显示 ¥49.9,心里换算一下觉得贵,关页面走人。更麻烦的是东南亚用户看到 $49.9,直接当成天价。不是产品不好,是价格根本没本地化——同一个价格,在不同国家用户眼里的「贵不贵」完全不同。
把价格做成「按地区显示本地币种 + 本地定价」,是出海产品的标配动作。实现这条链路的核心数据,就是用户当前的 IP 归属地:不用弹定位授权框(iOS/Android 问定位权限转化率掉一截),服务端拿 IP 查个归属地就知道用户在哪国。这篇用 JavaScript 实现:前端按 IP 归属地切币种展示,后端 Node 中间件重算价格防篡改,外加价格表怎么配、缓存怎么加、降级怎么做。
一、为什么必须按国家定不同的价
三个现实原因:
汇率错觉。 中国人的日常消费定价和美国人完全不同量级。一个月费 29.9 美元的产品,对印度用户可能是半个月饭钱。固定用一个币种卖全球,等于主动放弃高价市场之外的绝大多数用户。
购买力差异。 行业通行做法是「分档定价」:发达国家按美元/欧元定价,东南亚、南美、中东按当地购买力打折(常见 $9.99 的产品在印度卖 ₹299)。这不是歧视,是当地团队反复 A/B 测出来的最优解。
支付习惯和税务。 美国用户习惯订阅制+信用卡,欧洲要含 VAT 展示,日本用户习惯便利店支付。这些在「按国家/地区返回价格」这一层就要带出来,而不是等用户到收银台才踩坑。
二、方案设计:IP → 国家 → 价格表 → 展示
链路分四层:
- 取归属地:后端拿到用户请求 IP,调
https://ip9.com.cn/get?ip=目标IP拿country_code(免费版就支持,免注册)。不用 GPS 是因为浏览器/小程序拿定位要授权弹窗,转化率掉得肉疼,而且价格本地化不需要街道精度,国家/地区级足够。 - 查价格表:
country_code→ 查一张集中配置的定价表(MySQL 或 JSON 配置都行),得到「该国的币种、含税价、支付方式列表」。 - 返回本地化数据:后端把价格、币种、格式化参数一次性给前端;前端用
Intl.NumberFormat按币种格式化(美国$8.99、日本¥980、印度₹299),顺带把日期、数字格式一起本地化。 - 兜底与防篡改:查不到归属地的(内网 IP、代理黑名单等)回退默认 USD;前端只管展示,下单时后端必须用同一套逻辑重算价格——前端价格字符串随便改,后端价格错了才是真事故。
三、JavaScript 实现
3.1 后端:Node 中间件查归属地 + 返回本地化价格
用原生 http 搭一个最小服务演示核心链路,跑起来就是一个可用的 API:请求带 ?ip=1.2.3.4(生产环境从 req.socket.remoteAddress 或反代给的 X-Forwarded-For 取),返回该用户国家的价格、币种、格式化后的展示串。
javascript
// pricing.js —— 出海多币种定价演示(Node.js,无第三方依赖)
const http = require('http');
const https = require('https'); // IP9 接口走 https
// 价格表:country_code -> { currency, price(含税), symbol, locale }
// 未配置的国家回退 USD。生产环境放数据库/配置中心,登录页热更新。
const PRICE_TABLE = {
US: { currency: 'USD', price: 9.99, locale: 'en-US' },
JP: { currency: 'JPY', price: 980, locale: 'ja-JP' },
IN: { currency: 'INR', price: 299, locale: 'en-IN' },
BR: { currency: 'BRL', price: 29.90, locale: 'pt-BR' },
DE: { currency: 'EUR', price: 8.99, locale: 'de-DE' },
GB: { currency: 'GBP', price: 7.99, locale: 'en-GB' },
};
const FALLBACK = { currency: 'USD', price: 9.99, locale: 'en-US' };
// 调 IP9 免费接口查 IP 归属地(60 次/分钟/IP,生产环境务必加缓存与限速)
function queryGeo(ip) {
const url = `https://ip9.com.cn/get?ip=${encodeURIComponent(ip)}`;
return new Promise((resolve, reject) => {
const opts = { headers: { 'User-Agent': 'Mozilla/5.0 (ip9-pricing-demo)' } };
https.get(url, opts, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => {
try {
const d = JSON.parse(body);
// 非法 IP 返回 ret=400,限速返回 ret=429;归属地字段在 data 层
if (d.ret !== 200) return reject(new Error('geo api error: ' + d.ret));
resolve(d.data || {});
} catch (e) { reject(e); }
});
}).on('error', reject);
});
}
// 本地化缓存:country_code 级别的结果缓存 10 分钟,避免每个请求都调接口
const geoCache = new Map();
function getCached(cc) {
const hit = geoCache.get(cc);
return hit && hit.expire > Date.now() ? hit.plan : undefined;
}
function setCached(cc, plan) {
geoCache.set(cc, { plan, expire: Date.now() + 10 * 60 * 1000 });
}
// 核心:IP -> 国家 -> 定价方案
async function pricingFor(ip) {
const hit = getCached(ip);
if (hit) return hit;
const geo = await queryGeo(ip).catch(() => null); // 接口抖动时降级
// IP9 返回小写 country_code(us/jp/de),价格表 key 是大写,统一转大写
const cc = ((geo && geo.country_code) || 'us').toUpperCase();
const plan = PRICE_TABLE[cc] || FALLBACK;
setCached(ip, plan);
return plan;
}
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Access-Control-Allow-Origin', '*'); // 演示用;生产按域名收紧
try {
// 演示:从 query 取 ip;生产从 remoteAddress / X-Forwarded-For 取
const ip = new URL(req.url, 'http://x').searchParams.get('ip') || '8.8.8.8';
const plan = await pricingFor(ip);
// 格式化:$9.99 / ¥980 / ₹299
const formatted = new Intl.NumberFormat(plan.locale, {
style: 'currency', currency: plan.currency,
}).format(plan.price);
res.end(JSON.stringify({ ...plan, formatted, ip }));
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
server.listen(3000, () => console.log('pricing api: http://localhost:3000/?ip=1.2.3.4'));跑起来试几个 IP:curl 'http://localhost:3000/?ip=8.8.8.8'(美国)返回 USD 9.99 $9.99,换一个日本 IP 返回 JPY 980 ¥980。
3.2 前端:按接口结果切换币种展示
前端只做一件事:请求自己的后端拿 {currency, price, formatted},本地也存一份「价格表快照」用于秒开(离线也能显示上次的价格),后台刷新真实值。
javascript
// 前端价格展示:优先用后端返回值,失败时用快照或默认 USD
async function renderPrice() {
try {
const r = await fetch('/api/pricing', { cache: 'no-store' });
const p = await r.json();
localStorage.setItem('price_snapshot', JSON.stringify(p));
document.getElementById('price').textContent = p.formatted;
document.getElementById('currency').textContent = p.currency;
} catch (e) {
const snap = JSON.parse(localStorage.getItem('price_snapshot') || 'null');
if (snap) {
document.getElementById('price').textContent = snap.formatted;
} else {
document.getElementById('price').textContent = '$9.99'; // 最终兜底
}
}
}
renderPrice();注意一个实现细节:前端不要直接调 https://ip9.com.cn/get。 一个是浏览器跨域(CORS)不一定放开,另一个是免费额度按 IP 限速——全站用户共用你的出口 IP 直连,几秒就打满 60 次/分钟。正确姿势是请求自己的服务端,由后端查归属地(还能带服务端缓存),前端只拿到定价结果。
四、落地注意事项
价格表要支持「生效时间」和「灰度」。 调价是运营事故高发区。每条定价记录加 effective_from,前端拿到的永远是「当前时间生效的那一版」;新价格先 5% 灰度,观察退款率和客诉再放量。
汇率别实时拉。 价格跟着实时汇率跳,用户会看到价格忽高忽低,更容易触发退款纠纷。通常每天/每周同步一次固定汇率表,手动确认后上线。汇率表放独立配置,和价格表分开管理。
IP 归属地是「国家/城市级」线索,够定价用了。 IP9 免费版返回国家/省/市/运营商,能支撑「按国家定价」;但要提示的是,用户挂着 VPN 或代理(ip_type 机房特征)时归属地会漂移——VIP 版提供 ip_type 和 ip_asn,可以识别「机房出口」并在价格上保守处理(比如不针对低价区用户展示低价,减少薅低价区羊毛)。
防薅羊毛是定价系统的另一半。 低价区价格天然会被高价区用户用代理钻空子。常规手段:下单时用「IP 归属地 + 支付渠道所在国」双校验,不一致就打回重验(接受小额误伤,但能挡住批量代充);会员订阅续费也要按当前 IP 重算地区,防止「低价区开会员 → 高价区续费」。
别忘降级路径。 接口超时、查不到归属地(私网 IP、数据库缺失)、限速 429——这些情况都要有默认方案,页面不能白屏。通用兜底:默认 USD + 本地无缓存时显示美元;同时把失败计一下数,跌到阈值就切「全部按默认币种」,等接口恢复再切回来。
合规上别碰红线。 定价场景只需要国家/地区,不需要经纬度和个人身份信息。日志里可以留「country_code + 会话」用于统计,但没必要长期保留用户 IP 明细;数据出境、留存期限按公司合规要求处理。
总结
多币种地区定价的链路不复杂:IP 归属地 → 国家 → 定价表 → 本地化展示,全程不需要用户授权定位。核心风险不在「怎么查归属地」(免费接口 https://ip9.com.cn/get?ip=目标IP,官网 https://www.ip9.com.cn,免注册、支持 IPv4/IPv6、60 次/分钟,配上服务端缓存足够起步),而在「价格表怎么管、防篡改怎么做、降级怎么兜」。把上面 Node 中间件和前端兜底跑通,再补上汇率表和灰度开关,一个出海产品的价格本地化就齐了——用户看到的第一个数字,就是对的货币。