博客/Developer

用JavaScript构建黄金首饰价格计算器

从实时的每克成色价格出发构建一个黄金首饰价格计算器,涵盖精确的十进制运算、缓存、利润率,以及清晰的估值边界。

Developer

珠宝商、当铺和电商卖家都需要同一个小工具:输入重量和成色,得到材料价值。本文用纯JavaScript从零到一构建这个工具,从实时的每克价格一直到浏览器界面。

这个计算器估算的是什么,又不是什么

它输出的是材料价值:这件首饰所含黄金按当前现货价格值多少钱,尚未加上任何其他成本。它不是鉴定评估,不是有保证的回收价,也不是买家实际会支付的金额。

完全不包含以下内容:

  • 宝石、珐琅或首饰中任何非黄金材料
  • 工艺和设计(一枚手工打磨的戒指比冲压制成的成本更高)
  • 税费和关税,因国家和交易类型而异
  • 交易商价差:商铺回收价与现货报价之间的差距
  • 品相状态:划痕、缺失的宝石、磨损的雕刻
  • 商铺为保护自身利润而设的回收折扣

计算器给出的每一个数字都应该配上一行提示用户上述事实的说明。把这条免责声明做进界面本身,而不是只写在文档里。

第一步:获取八种成色的每克价格

API的 /v1/carat 端点会在一次调用中返回八种常见成色纯度的每克价格,无需API密钥:

GET https://api.goldprice.dev/v1/carat?currency=USD

真实响应看起来是这样:

{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}

对下面的代码来说,有两个细节很关键。每个价格字段都是字符串而非数字,这样精确的十进制数字才能在JSON往返中保持不变。而 currency 可以接受API支持的31种货币中的任意一种,因此只需改变一个查询参数,同一个端点就能覆盖USD、EUR、INR或IDR。

第二步:在服务端缓存60秒

上游数据本身以类似的节奏更新,因此每次页面加载都去轮询它没有任何好处,反而增加延迟。用一个带内存缓存的小型Node服务器放在前面:

import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);

这里只是纯粹的 node:http,单个端点无需任何框架。失败处理和成功路径同样重要:上游返回非200响应或响应体格式不正确都会抛出异常,而 getCarat 会在缓存存在且仍处于TTL窗口内时回退到该缓存值。只有在完全没有缓存的情况下,错误才会以502的形式传递给客户端。

不要从浏览器JavaScript直接调用 api.goldprice.dev。该API按设计没有浏览器CORS支持,因此客户端代码发起的 fetch 会失败。请让每个请求都经过你自己的服务器端点,这样做还能让缓存在所有访客之间共享,而不是每个浏览器标签页各自一份。

第三步:材料价值——重量乘以每克价格

核心公式只是一次乘法:weight_grams × per_gram_karat_price。问题在于两个操作数都必须保持精确的十进制运算,因为金额的浮点数乘法会产生漂移:

console.log(0.1 + 0.2); // 0.30000000000000004

API已经给出了精确的十进制字符串。解决办法是把它们保持为最小货币单位(分)的整数,而不是转换为 Number 再转回来:

function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}

这里不需要任何大数依赖库,只用现在每个JS运行时原生支持的 BigInt。重量以普通浮点数形式输入(珠宝秤精确到0.01g或0.001g),因此在相乘之前会先转换为整数毫克,而除回原单位的操作放在乘法之后最后进行,这样中间值才能保持精确。注意整数除法是截断而非四舍五入;在这段代码上线生产环境前,请为你所在货币的最小单位选定一条明确的舍入规则,就像API本身会将每个成色价格四舍五入到该货币自身的小数步长一样。

第四步:把利润率和加工费单独加上去

第三步得到的材料价值是一个由现货价格推导出的数字。它不是商铺该向顾客报出的价格。还有两项输入需要叠加在它上面,而且它们应作为独立的行项目存在,绝不能混入现货数字本身:

  • 商户利润率:商铺的加价或收购折扣,通常是一个百分比
  • 加工费:把原料黄金做成成品的成本,通常是固定费用或材料价值的一个百分比,与现货价格完全无关
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}

把这些保持为三个独立字段而不是一个混合总价,正是让计算器可审计的关键。顾客或监管者都应该能单独看到由现货价格推导出的材料价值那一行,与商铺选择加上去的部分区分开来。

第五步:显示时间戳、货币和边界说明

浏览器端只调用你自己的服务器,绝不直接调用上游API:

async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});

界面显示的每一个结果都需要在旁边附上时间戳和货币,以及下方一行固定的免责声明文字:这是材料价值估算,不是鉴定评估,且不包括宝石、工艺、税费和品相状态。把这句话写进模板一次即可,不要放进一个容易被忽略的提示气泡里。

第六步:处理请求失败和格式错误的数据

在服务器边界,失败有三种形态,上面的代码已经全部处理:

  • 上游请求本身失败(网络错误、超时):在 fetchCarat 中被捕获,回退到缓存
  • 响应是非200状态:显式检查,与网络失败同样处理
  • 响应体看起来不对(字段缺失、类型错误):typeof data.price_gram_24k !== "string" 这一检查会在错误的响应体被缓存或提供给客户端之前将其拦下

当存在一个新鲜的缓存条目时,以上情况都不会波及用户;他们会得到最后一个有效值及其真实时间戳。当完全没有可用缓存时,客户端会得到一个明确的错误状态,而不是一次损坏的计算结果或一个悄无声息的零值。

许可

本文全程使用的匿名端点适用于开发和测试。将原始API数据用于商业用途(在你收费的产品中,或用于产生收入),需要Pro套餐或更高层级的授权。如果你只需要在页面上展示一个实时价格,而不是用于计算的原始数据,官方可嵌入小组件可免费用于商业展示,且无需API密钥。

相关阅读

相关指南

Developer

Backtest local-currency gold without FX look-ahead bias

Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.

阅读 →
Developer

Gold price WebSocket on Reddit: the streaming questions, answered

The real-time gold streaming questions that come up on Reddit, answered: whether a gold WebSocket exists, how the goldprice.dev XAU and XAG tick stream works end to end, and which other APIs actually offer one (most metals APIs just poll).

阅读 →
Developer

Gold price API on Stack Overflow: developer troubleshooting, answered

The gold-price-API errors developers hit on Stack Overflow, fixed: 401 auth, 400 invalid_symbol, 429 rate limits, CORS from the browser, reading the response, and pulling historical data.

阅读 →

goldprice.dev

实时黄金价格、历史 OHLC 数据与多源聚合 — 通过 REST 和 SSE 接口提供。