chore: update app, infra configs, assets

这个提交包含在:
cryptocommuniums-afk
2026-02-02 10:05:00 +08:00
父节点 4c46b22c85
当前提交 810d0420d6
修改 18 个文件,包含 1374 行新增30 行删除

查看文件

@@ -56,6 +56,10 @@ Headers: `x-merchant-id`, `x-api-key`
```json
{ "orderId": "ORD123", "txHash": "0x...", "network": "evm:ethereum" }
```
Bitcoin 示例:
```json
{ "orderId": "ORD123", "txHash": "btc_txid", "network": "btc:mainnet" }
```
### 4) Webhook 聚合入口
`POST /payments/webhook`
@@ -70,12 +74,16 @@ Headers: `x-merchant-id`, `x-api-key`
证书建议使用 certbotLet’s Encrypt
```bash
apt-get update && apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d capay.hao.work -d pay.capay.hao.work -d btc.capay.hao.work -d doge.capay.hao.work
certbot --nginx -d capay.hao.work -d pay.capay.hao.work -d btc.capay.hao.work
```
## x402 Facilitator
配置文件在:`x402/facilitator/`,已绑定 `pay.capay.hao.work`,并写入 Alchemy ETH RPC。
## Bitcoin 节点Alchemy
默认使用 `ALCHEMY_BTC_API_KEY(S)``BTC_RPC_URL(S)` 来访问 Bitcoin 主网 RPC。
`/payments/track``network` 统一使用 `btc:mainnet`
## 生产建议
-`.env` 中的密钥替换为强随机值
- 生产环境建议使用数据库PostgreSQL/MySQL替换本地 JSON

查看文件

@@ -1,6 +1,7 @@
APP_PORT=3001
APP_HOST=https://capay.hao.work
PLAN=free
ADMIN_API_KEY=whoami139
# Low-cost polling for free plan
TX_POLL_INTERVAL_MS=180000
@@ -14,6 +15,13 @@ ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
# Optional: comma-separated RPC URLs (overrides/extends)
# ETHEREUM_RPC_URLS=https://eth-mainnet.g.alchemy.com/v2/KEY1,https://eth-mainnet.g.alchemy.com/v2/KEY2
# Bitcoin RPC via Alchemy (optional)
ALCHEMY_BTC_API_KEYS=P9kZiHB6Q7CLrBlMsUN3n
ALCHEMY_BTC_API_KEY=P9kZiHB6Q7CLrBlMsUN3n
# Optional: full RPC URL with auth (overrides/extends)
# BTC_RPC_URL=https://alchemy:YOUR_KEY@bitcoin-mainnet.g.alchemy.com/v2/YOUR_KEY
# BTC_RPC_URLS=https://alchemy:KEY1@bitcoin-mainnet.g.alchemy.com/v2/KEY1,https://alchemy:KEY2@bitcoin-mainnet.g.alchemy.com/v2/KEY2
# Webhook signature secrets (replace in production)
IPN_HMAC_SECRET=replace-with-strong-secret
BTCPAY_WEBHOOK_SECRET=replace-with-strong-secret

221
app/src/public/app.js 普通文件
查看文件

@@ -0,0 +1,221 @@
const adminKeyInput = document.getElementById('admin-key');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const loginSection = document.getElementById('login-section');
const appSection = document.getElementById('app-section');
const statusText = document.getElementById('status-text');
const statusPill = document.getElementById('status-pill');
const logoutButton = document.getElementById('logout');
const clearKeyButton = document.getElementById('clear-key');
const refreshButton = document.getElementById('refresh-merchants');
const merchantRows = document.getElementById('merchant-rows');
const merchantEmpty = document.getElementById('merchant-empty');
const merchantForm = document.getElementById('merchant-form');
const merchantResult = document.getElementById('merchant-result');
const tabs = document.querySelectorAll('.tabs button');
const panels = document.querySelectorAll('.tab-panel');
const curlCreateMerchant = document.getElementById('curl-create-merchant');
const curlCreateOrder = document.getElementById('curl-create-order');
const curlTrackTx = document.getElementById('curl-track-tx');
const webhookTip = document.getElementById('webhook-tip');
const STORAGE_KEY = 'capay_admin_key';
const baseUrl = window.location.origin;
function setStatus(text, ok) {
statusText.textContent = text;
statusPill.textContent = ok ? '已连接' : '未连接';
statusPill.style.borderColor = ok ? 'rgba(52, 211, 153, 0.6)' : 'rgba(248, 113, 113, 0.6)';
statusPill.style.color = ok ? '#34d399' : '#f87171';
}
function showLoginError(message) {
loginError.textContent = message;
loginError.classList.toggle('hidden', !message);
}
function getAdminKey() {
return localStorage.getItem(STORAGE_KEY) || '';
}
function setAdminKey(value) {
localStorage.setItem(STORAGE_KEY, value);
}
async function request(path, options = {}) {
const headers = options.headers ? { ...options.headers } : {};
const adminKey = getAdminKey();
if (adminKey) {
headers['x-admin-key'] = adminKey;
}
const res = await fetch(path, { ...options, headers });
if (!res.ok) {
const text = await res.text();
let message = text;
try {
message = JSON.parse(text).error || text;
} catch (error) {
// ignore
}
throw new Error(message || `request failed (${res.status})`);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function loadHealth() {
try {
const health = await request('/health', { headers: {} });
setStatus(`服务正常 · 计划 ${health.plan}`, true);
} catch (error) {
setStatus('无法连接服务', false);
}
}
function renderMerchants(merchants) {
merchantRows.innerHTML = '';
if (!merchants || merchants.length === 0) {
merchantEmpty.classList.remove('hidden');
return;
}
merchantEmpty.classList.add('hidden');
merchants.forEach((merchant) => {
const row = document.createElement('tr');
const apiKey = merchant.apiKey || '-';
const webhookSecret = merchant.webhookSecret || '-';
row.innerHTML = `
<td>${merchant.id}</td>
<td>${merchant.name}</td>
<td>${merchant.email}</td>
<td>${merchant.webhookUrl || '-'}</td>
<td><span class="chip" data-value="${apiKey}">${apiKey}</span></td>
<td><span class="chip" data-value="${webhookSecret}">${webhookSecret}</span></td>
<td>${merchant.createdAt || '-'}</td>
`;
row.querySelectorAll('.chip').forEach((chip) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '复制';
btn.className = 'ghost copy-btn';
btn.addEventListener('click', () => {
navigator.clipboard.writeText(chip.dataset.value || '');
btn.textContent = '已复制';
setTimeout(() => (btn.textContent = '复制'), 1200);
});
chip.appendChild(btn);
});
merchantRows.appendChild(row);
});
}
async function loadMerchants() {
try {
const data = await request('/admin/merchants');
renderMerchants(data.merchants || []);
} catch (error) {
renderMerchants([]);
showLoginError(error.message);
}
}
function updateGuide() {
const adminKey = getAdminKey() || '<ADMIN_API_KEY>';
curlCreateMerchant.textContent = `curl -X POST ${baseUrl}/admin/merchants \\\n -H \"Content-Type: application/json\" \\\n -H \"X-Admin-Key: ${adminKey}\" \\\n -d '{\"name\":\"Acme\",\"email\":\"ops@acme.com\",\"webhookUrl\":\"https://merchant.example.com/webhook\"}'`;
curlCreateOrder.textContent = `curl -X POST ${baseUrl}/payments/orders \\\n -H \"Content-Type: application/json\" \\\n -H \"x-merchant-id: <merchantId>\" \\\n -H \"x-api-key: <apiKey>\" \\\n -d '{\"amount\":\"9.99\",\"currency\":\"USD\",\"network\":\"evm:ethereum\",\"asset\":\"USDC\",\"description\":\"API Access\"}'`;
curlTrackTx.textContent = `curl -X POST ${baseUrl}/payments/track \\\n -H \"Content-Type: application/json\" \\\n -H \"x-merchant-id: <merchantId>\" \\\n -H \"x-api-key: <apiKey>\" \\\n -d '{\"orderId\":\"<orderId>\",\"txHash\":\"<txHash>\",\"network\":\"btc:mainnet\"}'`;
webhookTip.textContent = `Webhook 入口: ${baseUrl}/payments/webhook\n签名头:\n- Keagate: x-keagate-sig (sha512 HMAC)\n- BTCPay: btcpay-sig (sha256 HMAC)\n- x402: x402-sig (sha256 HMAC)\n商户侧使用 webhookSecret 验签后处理。`;
}
function showApp() {
loginSection.classList.add('hidden');
appSection.classList.remove('hidden');
showLoginError('');
updateGuide();
loadHealth();
loadMerchants();
}
function showLogin() {
loginSection.classList.remove('hidden');
appSection.classList.add('hidden');
}
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
const key = adminKeyInput.value.trim();
if (!key) {
showLoginError('请输入管理员密钥');
return;
}
setAdminKey(key);
try {
await request('/admin/merchants');
showApp();
} catch (error) {
showLoginError(error.message || '登录失败');
}
});
clearKeyButton.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
adminKeyInput.value = '';
showLoginError('');
});
logoutButton.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
adminKeyInput.value = '';
showLogin();
});
refreshButton.addEventListener('click', () => {
loadMerchants();
});
merchantForm.addEventListener('submit', async (event) => {
event.preventDefault();
merchantResult.classList.add('hidden');
const name = document.getElementById('merchant-name').value.trim();
const email = document.getElementById('merchant-email').value.trim();
const webhookUrl = document.getElementById('merchant-webhook').value.trim();
try {
const result = await request('/admin/merchants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, webhookUrl: webhookUrl || null })
});
merchantResult.textContent = `创建成功merchantId=${result.merchantId}`;
merchantResult.classList.remove('hidden');
merchantForm.reset();
await loadMerchants();
} catch (error) {
merchantResult.textContent = `创建失败:${error.message}`;
merchantResult.classList.remove('hidden');
}
});
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tabs.forEach((item) => item.classList.remove('active'));
tab.classList.add('active');
const target = tab.dataset.tab;
panels.forEach((panel) => {
panel.classList.toggle('hidden', panel.id !== `tab-${target}`);
});
});
});
(function init() {
const key = getAdminKey();
if (key) {
adminKeyInput.value = key;
showApp();
} else {
showLogin();
}
loadHealth();
})();

查看文件

@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Capay API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<link rel="stylesheet" href="/styles.css" />
<style>
.docs-header {
max-width: 1180px;
margin: 24px auto 0;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.docs-header a {
text-decoration: none;
}
.swagger-ui .topbar {
display: none;
}
.swagger-ui {
max-width: 1180px;
margin: 12px auto 60px;
padding: 0 12px;
}
.swagger-ui .info .title {
font-family: "Fraunces", "Times New Roman", serif;
}
.swagger-ui .opblock-tag {
color: #e2e8f0;
}
</style>
</head>
<body>
<div class="docs-header">
<div>
<h2>Capay API Docs</h2>
<p class="muted">Swagger-style documentation with live requests.</p>
</div>
<a class="pill link" href="/">Back to Admin</a>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis],
layout: "BaseLayout"
});
};
</script>
</body>
</html>

136
app/src/public/index.html 普通文件
查看文件

@@ -0,0 +1,136 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Capay 管理后台</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page">
<header class="hero">
<div>
<h1>Capay 管理后台</h1>
<p>商户管理 · REST 配置 · 接入指导</p>
</div>
<div class="hero-meta">
<span class="pill">API Admin</span>
<span class="pill" id="status-pill">未连接</span>
<a class="pill link" href="/docs/">API Docs</a>
</div>
</header>
<section id="login-section" class="card">
<h2>管理员登录</h2>
<p class="muted">使用 ADMIN_API_KEY 进入管理后台默认whoami139</p>
<form id="login-form">
<label>
管理员密钥
<input id="admin-key" type="password" placeholder="X-Admin-Key" autocomplete="current-password" />
</label>
<div class="form-actions">
<button type="submit" class="primary">登录</button>
<button type="button" id="clear-key" class="ghost">清除</button>
</div>
<p id="login-error" class="error hidden"></p>
</form>
</section>
<section id="app-section" class="hidden">
<div class="toolbar">
<div class="toolbar-left">
<span id="status-text" class="muted">连接中...</span>
</div>
<div class="toolbar-right">
<button id="refresh-merchants" class="ghost">刷新商户</button>
<button id="logout" class="ghost">退出</button>
</div>
</div>
<nav class="tabs">
<button data-tab="merchants" class="active">商户列表</button>
<button data-tab="create">新增商户</button>
<button data-tab="guide">REST 管理配置与教程</button>
</nav>
<div id="tab-merchants" class="tab-panel card">
<h2>已接入商户</h2>
<p class="muted">支持多商户并行接入,复制 API Key / Secret 交付对应商户即可。</p>
<div id="merchant-table" class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>邮箱</th>
<th>Webhook</th>
<th>API Key</th>
<th>Secret</th>
<th>创建时间</th>
</tr>
</thead>
<tbody id="merchant-rows"></tbody>
</table>
</div>
<p id="merchant-empty" class="muted hidden">暂无商户,请先新增。</p>
</div>
<div id="tab-create" class="tab-panel card hidden">
<h2>新增商户</h2>
<form id="merchant-form">
<div class="form-grid">
<label>
商户名称
<input id="merchant-name" type="text" required />
</label>
<label>
邮箱
<input id="merchant-email" type="email" required />
</label>
<label class="span-2">
Webhook URL可选
<input id="merchant-webhook" type="url" placeholder="https://merchant.example.com/webhook" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="primary">创建商户</button>
<button type="reset" class="ghost">重置</button>
</div>
<p id="merchant-result" class="success hidden"></p>
</form>
</div>
<div id="tab-guide" class="tab-panel card hidden">
<h2>REST 管理配置与教程</h2>
<div class="guide">
<ol>
<li>使用管理员密钥创建商户,获取 merchantId / apiKey / webhookSecret。</li>
<li>将 apiKey 与 merchantId 提供给商户,用于创建订单与跟踪交易。</li>
<li>商户创建订单后,把链上 txHash 回传到 /payments/track 进行确认。</li>
<li>Webhook 由平台签名,商户侧自行验签并处理订单状态。</li>
</ol>
</div>
<h3>1. 创建商户(管理员)</h3>
<pre><code id="curl-create-merchant"></code></pre>
<h3>2. 创建订单(商户)</h3>
<pre><code id="curl-create-order"></code></pre>
<h3>3. 追踪链上交易</h3>
<pre><code id="curl-track-tx"></code></pre>
<h3>4. Webhook 验签提示</h3>
<pre><code id="webhook-tip"></code></pre>
<div class="callout">
<strong>多商户接入</strong>
<p>每个商户都会得到独立的 merchantId/apiKey/webhookSecret。重复创建即可支持不同商户并行接入。</p>
</div>
</div>
</section>
</div>
<script src="/app.js"></script>
</body>
</html>

365
app/src/public/openapi.json 普通文件
查看文件

@@ -0,0 +1,365 @@
{
"openapi": "3.0.3",
"info": {
"title": "Capay API",
"version": "0.1.0",
"description": "Capay merchant payment API with admin controls."
},
"servers": [
{
"url": "/"
}
],
"tags": [
{ "name": "Admin", "description": "Admin merchant management" },
{ "name": "Merchant", "description": "Merchant order & tracking APIs" },
{ "name": "Webhook", "description": "Unified webhook entry" }
],
"components": {
"securitySchemes": {
"adminKey": {
"type": "apiKey",
"in": "header",
"name": "X-Admin-Key"
},
"merchantId": {
"type": "apiKey",
"in": "header",
"name": "x-merchant-id"
},
"merchantApiKey": {
"type": "apiKey",
"in": "header",
"name": "x-api-key"
}
},
"schemas": {
"MerchantCreateRequest": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"webhookUrl": { "type": "string", "format": "uri", "nullable": true }
}
},
"MerchantCredentials": {
"type": "object",
"properties": {
"merchantId": { "type": "string" },
"apiKey": { "type": "string" },
"webhookSecret": { "type": "string" }
}
},
"MerchantPublic": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"email": { "type": "string" },
"webhookUrl": { "type": "string", "nullable": true },
"createdAt": { "type": "string", "format": "date-time" }
}
},
"MerchantFull": {
"allOf": [
{ "$ref": "#/components/schemas/MerchantPublic" },
{
"type": "object",
"properties": {
"apiKey": { "type": "string" },
"webhookSecret": { "type": "string" }
}
}
]
},
"OrderRequest": {
"type": "object",
"required": ["amount", "currency", "network", "asset"],
"properties": {
"amount": { "type": "string" },
"currency": { "type": "string" },
"network": { "type": "string" },
"asset": { "type": "string" },
"description": { "type": "string", "nullable": true },
"lockWindowSeconds": { "type": "number" },
"slippage": { "type": "number" },
"orderId": { "type": "string" }
}
},
"TrackRequest": {
"type": "object",
"required": ["orderId", "txHash", "network"],
"properties": {
"orderId": { "type": "string" },
"txHash": { "type": "string" },
"network": { "type": "string" }
}
},
"Order": {
"type": "object",
"properties": {
"id": { "type": "string" },
"merchantId": { "type": "string" },
"amount": { "type": "string" },
"currency": { "type": "string" },
"network": { "type": "string" },
"asset": { "type": "string" },
"description": { "type": "string", "nullable": true },
"status": { "type": "string" },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
},
"TrackResponse": {
"type": "object",
"properties": {
"tracked": { "type": "boolean" },
"tx": {
"type": "object",
"properties": {
"id": { "type": "string" },
"orderId": { "type": "string" },
"network": { "type": "string" },
"txHash": { "type": "string" },
"status": { "type": "string" },
"confirmations": { "type": "number" },
"lastCheckedAt": { "type": "string", "format": "date-time", "nullable": true },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
}
}
}
}
}
},
"paths": {
"/health": {
"get": {
"summary": "Health check",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": { "type": "string" },
"plan": { "type": "string" }
}
}
}
}
}
}
}
},
"/merchants": {
"post": {
"tags": ["Admin"],
"summary": "Create merchant (admin)",
"security": [{ "adminKey": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/MerchantCreateRequest" }
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/MerchantCredentials" }
}
}
}
}
}
},
"/merchants/{id}": {
"get": {
"tags": ["Admin"],
"summary": "Get merchant public profile",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/MerchantPublic" }
}
}
}
}
}
},
"/admin/merchants": {
"get": {
"tags": ["Admin"],
"summary": "List merchants (admin)",
"security": [{ "adminKey": [] }],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"merchants": {
"type": "array",
"items": { "$ref": "#/components/schemas/MerchantFull" }
}
}
}
}
}
}
}
},
"post": {
"tags": ["Admin"],
"summary": "Create merchant (admin)",
"security": [{ "adminKey": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/MerchantCreateRequest" }
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/MerchantCredentials" }
}
}
}
}
}
},
"/payments/orders": {
"post": {
"tags": ["Merchant"],
"summary": "Create order",
"security": [{ "merchantId": [], "merchantApiKey": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/OrderRequest" }
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"order": { "$ref": "#/components/schemas/Order" },
"paymentRequirements": { "type": "object" },
"paymentRequirementsBase64": { "type": "string" }
}
}
}
}
}
}
}
},
"/payments/orders/{id}": {
"get": {
"tags": ["Merchant"],
"summary": "Get order",
"security": [{ "merchantId": [], "merchantApiKey": [] }],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Order" }
}
}
}
}
}
},
"/payments/track": {
"post": {
"tags": ["Merchant"],
"summary": "Track transaction",
"security": [{ "merchantId": [], "merchantApiKey": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/TrackRequest" }
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/TrackResponse" }
}
}
}
}
}
},
"/payments/webhook": {
"post": {
"tags": ["Webhook"],
"summary": "Unified webhook entry",
"parameters": [
{
"name": "X-Source",
"in": "header",
"required": true,
"schema": { "type": "string" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "type": "object" }
}
}
},
"responses": {
"200": {
"description": "OK"
}
}
}
}
}
}

292
app/src/public/styles.css 普通文件
查看文件

@@ -0,0 +1,292 @@
@import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap");
:root {
color-scheme: dark;
--bg: #0b0f19;
--panel: #0f172a;
--panel-strong: #111d35;
--text: #e2e8f0;
--muted: #9aa4b2;
--accent: #38bdf8;
--accent-2: #f97316;
--stroke: rgba(148, 163, 184, 0.25);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
body::before {
content: "";
position: fixed;
inset: 0;
background:
radial-gradient(circle at 12% 20%, rgba(56, 189, 248, 0.2), transparent 45%),
radial-gradient(circle at 90% 10%, rgba(249, 115, 22, 0.16), transparent 38%),
linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(11, 15, 25, 0.98));
z-index: -2;
}
body::after {
content: "";
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
background-size: 28px 28px;
opacity: 0.35;
z-index: -1;
pointer-events: none;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 36px 24px 90px;
display: grid;
gap: 24px;
}
.hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
padding: 28px;
border-radius: 22px;
background: linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(17, 29, 53, 0.95));
border: 1px solid rgba(56, 189, 248, 0.25);
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.6);
}
.hero h1 {
margin: 0 0 8px;
font-family: "Fraunces", "Times New Roman", serif;
font-size: 30px;
letter-spacing: 0.6px;
}
.hero p {
margin: 0;
color: var(--muted);
}
.hero-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.pill {
padding: 6px 14px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.7);
border: 1px solid var(--stroke);
font-size: 12px;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--text);
}
.pill.link {
text-decoration: none;
border-color: rgba(56, 189, 248, 0.45);
color: var(--accent);
}
.card {
background: rgba(15, 23, 42, 0.95);
border: 1px solid var(--stroke);
border-radius: 20px;
padding: 26px;
box-shadow: 0 18px 50px rgba(2, 6, 23, 0.55);
backdrop-filter: blur(6px);
}
.muted {
color: var(--muted);
}
.hidden {
display: none !important;
}
form {
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
font-size: 14px;
color: var(--muted);
}
input {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: #0b1224;
color: var(--text);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus {
outline: none;
border-color: rgba(56, 189, 248, 0.6);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
}
.form-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
button {
border: none;
padding: 10px 18px;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
font-family: "IBM Plex Sans", sans-serif;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
button:hover {
transform: translateY(-1px);
}
.primary {
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
color: #03121f;
box-shadow: 0 14px 28px rgba(14, 165, 233, 0.25);
}
.ghost {
background: transparent;
color: var(--text);
border: 1px solid rgba(148, 163, 184, 0.4);
}
.error {
color: #fca5a5;
}
.success {
color: #6ee7b7;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
gap: 12px;
}
.tabs {
display: flex;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.tabs button {
background: #0b1224;
border: 1px solid rgba(148, 163, 184, 0.3);
color: var(--text);
}
.tabs button.active {
border-color: rgba(56, 189, 248, 0.7);
color: var(--accent);
}
.tab-panel {
margin-bottom: 16px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
text-align: left;
padding: 12px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
tbody tr:hover {
background: rgba(56, 189, 248, 0.08);
}
th {
color: #cbd5f5;
font-weight: 600;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.35);
font-size: 12px;
background: rgba(15, 23, 42, 0.6);
}
.copy-btn {
margin-left: 6px;
font-size: 11px;
padding: 4px 8px;
}
.form-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.span-2 {
grid-column: span 2;
}
pre {
background: #0b1224;
padding: 16px;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.2);
overflow-x: auto;
color: #cbd5f5;
}
.guide ol {
margin: 0 0 16px 20px;
}
.callout {
margin-top: 20px;
padding: 16px;
border-radius: 14px;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.3);
}

查看文件

@@ -3,13 +3,17 @@ require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const { nanoid } = require('nanoid');
const path = require('path');
const { readDb, withDb } = require('./storage');
const { startEvmWatcher } = require('./watchers/evmWatcher');
const { startBtcWatcher } = require('./watchers/btcWatcher');
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const PLAN = process.env.PLAN || 'free';
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'whoami139';
function nowIso() {
return new Date().toISOString();
@@ -40,6 +44,15 @@ async function requireMerchant(req, res) {
return merchant;
}
function requireAdmin(req, res) {
const adminKey = getHeader(req, 'x-admin-key');
if (!adminKey || adminKey !== ADMIN_API_KEY) {
res.status(403).json({ error: 'invalid admin key' });
return false;
}
return true;
}
async function logEvent(source, payload) {
await withDb((db) => {
db.events.push({
@@ -96,10 +109,45 @@ app.get('/health', (req, res) => {
});
app.get('/', (req, res) => {
res.status(200).send('Capay API online. Use /health for status.');
res.status(200).sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/admin/merchants', async (req, res) => {
if (!requireAdmin(req, res)) return;
const db = await readDb();
res.json({ merchants: db.merchants });
});
app.post('/admin/merchants', async (req, res) => {
if (!requireAdmin(req, res)) return;
const { name, email, webhookUrl } = req.body || {};
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const merchant = {
id: nanoid(),
name,
email,
webhookUrl: webhookUrl || null,
apiKey: nanoid(32),
webhookSecret: nanoid(32),
createdAt: nowIso()
};
await withDb((db) => {
db.merchants.push(merchant);
});
res.status(201).json({
merchantId: merchant.id,
apiKey: merchant.apiKey,
webhookSecret: merchant.webhookSecret
});
});
app.post('/merchants', async (req, res) => {
if (!requireAdmin(req, res)) return;
const { name, email, webhookUrl } = req.body || {};
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
@@ -281,4 +329,5 @@ const port = Number(process.env.APP_PORT || 3000);
app.listen(port, () => {
console.log(`capay app listening on :${port}`);
startEvmWatcher({ notifyMerchant });
startBtcWatcher({ notifyMerchant });
});

查看文件

@@ -10,6 +10,10 @@ function buildAlchemyUrls(keys) {
return keys.map((key) => `https://eth-mainnet.g.alchemy.com/v2/${key}`);
}
function buildAlchemyBitcoinUrls(keys) {
return keys.map((key) => `https://alchemy:${key}@bitcoin-mainnet.g.alchemy.com/v2/${key}`);
}
function unique(items) {
return Array.from(new Set(items));
}
@@ -34,6 +38,26 @@ function getEthereumRpcUrls() {
return unique(urls);
}
function getBitcoinRpcUrls() {
const urls = [];
const explicitUrls = parseList(process.env.BTC_RPC_URLS);
urls.push(...explicitUrls);
if (process.env.BTC_RPC_URL) {
urls.push(process.env.BTC_RPC_URL);
}
const keyList = parseList(process.env.ALCHEMY_BTC_API_KEYS);
if (keyList.length > 0) {
urls.push(...buildAlchemyBitcoinUrls(keyList));
} else if (process.env.ALCHEMY_BTC_API_KEY) {
urls.push(...buildAlchemyBitcoinUrls([process.env.ALCHEMY_BTC_API_KEY]));
}
return unique(urls);
}
function createRoundRobinPicker(items) {
let index = 0;
return function pick() {
@@ -46,5 +70,6 @@ function createRoundRobinPicker(items) {
module.exports = {
getEthereumRpcUrls,
getBitcoinRpcUrls,
createRoundRobinPicker
};

查看文件

@@ -0,0 +1,103 @@
const { readDb, writeDb } = require('../storage');
const { getBitcoinRpcUrls, createRoundRobinPicker } = require('../utils/rpc');
const BTC_NETWORK = 'btc:mainnet';
async function callRpc(url, method, params = []) {
const body = JSON.stringify({
jsonrpc: '1.0',
id: 'capay',
method,
params
});
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
if (!response.ok) {
throw new Error(`rpc ${method} failed (${response.status})`);
}
const payload = await response.json();
if (payload.error) {
const message = payload.error.message || JSON.stringify(payload.error);
throw new Error(`rpc ${method} error: ${message}`);
}
return payload.result;
}
function startBtcWatcher({ notifyMerchant }) {
const rpcUrls = getBitcoinRpcUrls();
if (!rpcUrls.length) {
console.warn('[btcWatcher] No Bitcoin RPC URLs configured. Skipping watcher.');
return;
}
const pollInterval = Number(process.env.TX_POLL_INTERVAL_MS || 180000);
const maxPerCycle = Number(process.env.MAX_TX_CHECK_PER_CYCLE || 20);
const minConfirmations = Number(process.env.MIN_CONFIRMATIONS || 1);
const pickRpcUrl = createRoundRobinPicker(rpcUrls);
let running = false;
async function poll() {
if (running) return;
running = true;
try {
const db = await readDb();
const pending = db.txs.filter((tx) => tx.network === BTC_NETWORK && tx.status === 'pending');
if (pending.length === 0) {
running = false;
return;
}
const rpcUrl = pickRpcUrl();
const now = new Date().toISOString();
const confirmedEvents = [];
for (const tx of pending.slice(0, maxPerCycle)) {
try {
const result = await callRpc(rpcUrl, 'getrawtransaction', [tx.txHash, true]);
const confirmations = Number(result?.confirmations || 0);
tx.confirmations = confirmations;
tx.lastCheckedAt = now;
tx.updatedAt = now;
if (confirmations >= minConfirmations) {
tx.status = 'confirmed';
const order = db.orders.find((item) => item.id === tx.orderId);
if (order) {
order.status = 'paid';
order.updatedAt = now;
confirmedEvents.push({ order, tx });
}
}
} catch (error) {
tx.lastCheckedAt = now;
console.warn('[btcWatcher] rpc error', tx.txHash, error.message);
}
}
await writeDb(db);
for (const event of confirmedEvents) {
await notifyMerchant(event.order, event.tx);
}
} catch (error) {
console.error('[btcWatcher] poll error', error.message);
} finally {
running = false;
}
}
poll();
setInterval(poll, pollInterval);
console.log(`[btcWatcher] started with interval ${pollInterval}ms`);
}
module.exports = { startBtcWatcher };

子模块 infra/docker/btcpay-src 已添加到 3bd29ae5a4

查看文件

@@ -0,0 +1,43 @@
version: "3"
services:
postgres:
image: postgres:9.6.5
restart: unless-stopped
volumes:
- "postgres_datadir:/var/lib/postgresql/data"
nbxplorer:
image: nicolasdorier/nbxplorer:1.0.2.31
restart: unless-stopped
environment:
NBXPLORER_NETWORK: mainnet
NBXPLORER_BIND: 0.0.0.0:32838
NBXPLORER_CHAINS: "btc"
NBXPLORER_BTCRPCURL: https://alchemy:P9kZiHB6Q7CLrBlMsUN3n@bitcoin-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
NBXPLORER_BTCNODEENDPOINT: bitcoin-mainnet.g.alchemy.com:443
NBXPLORER_BTCRPCUSER: "alchemy"
NBXPLORER_BTCRPCPASSWORD: "P9kZiHB6Q7CLrBlMsUN3n"
volumes:
- "nbxplorer_datadir:/datadir"
depends_on:
- postgres
btcpayserver:
image: nicolasdorier/btcpayserver:1.0.2.106
restart: unless-stopped
ports:
- "23000:49392"
environment:
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Database=btcpayservermainnet
BTCPAY_NETWORK: mainnet
BTCPAY_BIND: 0.0.0.0:49392
BTCPAY_EXTERNALURL: https://btc.capay.hao.work/
BTCPAY_ROOTPATH: /
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
depends_on:
- postgres
- nbxplorer
volumes:
postgres_datadir:
nbxplorer_datadir:

子模块 infra/docker/keagate-src 已添加到 0581525abb

查看文件

@@ -1,5 +1,5 @@
{
"HOST": "https://doge.capay.hao.work",
"HOST": "https://capay.hao.work",
"PORT": 8081,
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
"MONGO_KEAGATE_DB": "keagate",
@@ -14,11 +14,6 @@
"INVOICE_ENC_KEY": "hex-32-bytes",
"IPN_HMAC_SECRET": "replace-hmac-secret",
"DOGE": {
"ADMIN_PUBLIC_KEY": "DogePublicAddress",
"ADMIN_PRIVATE_KEY": null
},
"SOL": {
"ADMIN_PUBLIC_KEY": "SolPublicAddress",
"ADMIN_PRIVATE_KEY": null

1
infra/docker/x402-rs 子模块

子模块 infra/docker/x402-rs 已添加到 4b0134acb1

查看文件

@@ -1,6 +1,6 @@
server {
listen 80;
server_name capay.hao.work pay.capay.hao.work btc.capay.hao.work doge.capay.hao.work;
server_name capay.hao.work pay.capay.hao.work btc.capay.hao.work;
return 301 https://$host$request_uri;
}
@@ -61,20 +61,3 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name doge.capay.hao.work;
# ssl_certificate /etc/letsencrypt/live/doge.capay.hao.work/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/doge.capay.hao.work/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

查看文件

@@ -4,14 +4,16 @@ LOG_LEVEL=info
FACILITATOR_JWT_SECRET=replace-with-strong-secret
PAYMENT_HMAC_SECRET=replace-with-strong-secret
EVM_PRIVATE_KEY=da31295a02cb4bf55be60827d72be87c60d7c40efc9b10f6f04dd87e97735da5
SOLANA_PRIVATE_KEY=fQ6DNzNNJmwiuB9VQSCn5UnwrJktp1ZKsgkmds8NsQowgswv58TGibnJpQkKcLzQtHztizvshfcQVoCJBZrfWsB
# Alchemy Ethereum RPC
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
# Optional BSC RPC if needed
BSC_RPC_URL=https://bsc-dataseed.binance.org
# Optional BSC RPC (Alchemy BNB Mainnet)
BSC_RPC_URL=https://bnb-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
# Solana RPC (optional)
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
# Solana RPC (Alchemy)
SOLANA_RPC_URL=https://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
REDIS_URL=redis://127.0.0.1:6379

查看文件

@@ -0,0 +1,49 @@
{
"port": 4020,
"host": "0.0.0.0",
"chains": {
"eip155:1": {
"eip1559": true,
"signers": ["$EVM_PRIVATE_KEY"],
"rpc": [
{
"http": "https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
"rate_limit": 20
}
]
},
"eip155:56": {
"eip1559": false,
"signers": ["$EVM_PRIVATE_KEY"],
"rpc": [
{
"http": "https://bnb-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
"rate_limit": 20
}
]
},
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": {
"signer": "$SOLANA_PRIVATE_KEY",
"rpc": "https://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
"pubsub": "wss://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n"
}
},
"schemes": [
{
"id": "v1-eip155-exact",
"chains": "eip155:*"
},
{
"id": "v2-eip155-exact",
"chains": "eip155:*"
},
{
"id": "v1-solana-exact",
"chains": "solana:*"
},
{
"id": "v2-solana-exact",
"chains": "solana:*"
}
]
}