Files
u-claw/portable/lib/xiapan-client.mjs
hfshfg 9ecbbd445c feat: 内置虾盘云 + 设备指纹绑定 + Release 流水线
- 新增 portable/lib/{fingerprint,xiapan-client,bootstrap-xiapan}.mjs:
  跨平台设备指纹(Win USB/disk + Mac UUID + Linux machine-id + seed 兜底)
  生成 sk-uc-{fingerprint} 形式的虾盘云 apiKey,启动时 merge 到 openclaw.json
- Config.html 顶部新增「已绑定虾盘云」横幅:指纹来源 + Key + 余额 + 充值/解绑
- config-server 增加 /api/xiapan/{status,bind,unbind} 三个端点
- Electron app 在 whenReady 调 bootstrap,新增 sync-lib.js 让 build 前自动同步
- 锁 OpenClaw 版本到 2026.4.29(OPENCLAW_VERSION 单一来源)
- 删除死代码 portable/充值.html
- 新增 .github/workflows/release.yml:tag 触发,出 portable zip + Electron exe/dmg
- README 增加内置虾盘云说明 + 直接下载发行版指引
2026-05-02 17:07:42 +08:00

92 lines
3.0 KiB
JavaScript

// Xiapan Cloud (虾盘云) client for U-Claw open-source edition.
// Provides only: apiKey derivation, balance lookup, recharge URL.
// Intentionally does NOT call /recharge/activate — open-source users do not get free quota.
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
const DEFAULT_RECHARGE_PAGE = 'https://u-claw.org/cloud.html';
const QUOTA_PER_USD = 500_000; // 1 USD = 500k tokens (matches new-api convention)
const REQUEST_TIMEOUT_MS = 10_000;
// sk-uc- prefix marks keys generated by the u-claw open-source edition.
// ClawX commercial keys use plain sk-<hash> and the cloud.html flow uses sk-xp-,
// so the three namespaces never collide and the backend can audit by prefix.
export function buildApiKey(fingerprint) {
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
throw new Error('Fingerprint must be 64-character hex.');
}
return `sk-uc-${fingerprint.toLowerCase()}`;
}
function getApiBase() {
return (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
}
function getRechargePage() {
return process.env.UCLAW_CLOUD_RECHARGE_PAGE || DEFAULT_RECHARGE_PAGE;
}
async function fetchWithTimeout(url, init) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function getBalance(apiKey) {
if (!apiKey) throw new Error('apiKey is required.');
const base = getApiBase();
const headers = { Authorization: `Bearer ${apiKey}` };
const [subRes, usageRes] = await Promise.all([
fetchWithTimeout(`${base}/dashboard/billing/subscription`, { headers }).catch(() => null),
fetchWithTimeout(
`${base}/dashboard/billing/usage?start_date=2020-01-01&end_date=${new Date().toISOString().slice(0, 10)}`,
{ headers },
).catch(() => null),
]);
if (!subRes || !subRes.ok) {
return {
ok: false,
reason: subRes ? `subscription HTTP ${subRes.status}` : 'subscription request failed',
hardLimitUsd: 0,
usedUsd: 0,
remainingUsd: 0,
remainingTokens: 0,
};
}
const subscription = await subRes.json().catch(() => ({}));
let usedUsd = 0;
if (usageRes && usageRes.ok) {
const usage = await usageRes.json().catch(() => ({}));
// total_usage is in cents (USD * 100), per new-api convention
usedUsd = Number(usage.total_usage || 0) / 100;
}
const hardLimitUsd = Number(subscription.hard_limit_usd || 0);
const remainingUsd = Math.max(0, hardLimitUsd - usedUsd);
const remainingTokens = Math.round(remainingUsd * QUOTA_PER_USD);
return {
ok: true,
reason: null,
hardLimitUsd,
usedUsd,
remainingUsd,
remainingTokens,
};
}
export function getRechargeUrl(apiKey) {
if (!apiKey) throw new Error('apiKey is required.');
const page = getRechargePage();
const url = new URL(page);
url.searchParams.set('key', apiKey);
// Page already has #recharge anchor; preserve it
return `${url.toString()}#recharge`;
}