diff --git a/token-meter/dashboard.html b/token-meter/dashboard.html
index c1177cc..58f5815 100644
--- a/token-meter/dashboard.html
+++ b/token-meter/dashboard.html
@@ -62,6 +62,15 @@ body.dragging-side .sidebar,body.dragging-side .main{transition:none}
.ch-main{display:flex;align-items:center;gap:8px;min-width:0}
.ch-status{width:100%;font-size:.72rem;opacity:.85;margin-top:3px;padding-left:23px}
.ch-source{width:100%;font-size:.68rem;opacity:.55;margin-top:2px;padding-left:23px;word-break:break-all}
+.ch-bills{width:100%;margin-top:4px;padding-left:23px;font-size:.7rem}
+.ch-bills-head{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
+.bill-title{opacity:.6}
+.bill-sync-btn{font-size:.66rem;padding:1px 8px;border:1px solid var(--border);background:transparent;color:var(--text);border-radius:6px;cursor:pointer;opacity:.85}
+.bill-sync-btn:hover{background:var(--hover)}
+.bill-sync-btn:disabled{opacity:.45;cursor:wait}
+.bill-row{display:flex;justify-content:space-between;align-items:center;padding:1px 0;opacity:.75;font-family:Consolas,monospace}
+.bill-total{border-top:1px dashed rgba(255,255,255,.14);margin-top:2px;padding-top:2px;opacity:.95;font-weight:600}
+.bill-empty{opacity:.5;padding:2px 0}
.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:#95a5a6}
.status-dot.ok{background:#2ecc71;box-shadow:0 0 6px #2ecc71}
.status-dot.warn{background:#f39c12}
@@ -548,6 +557,30 @@ function manualUpdateChannel(id){
}catch(_){/* 刷新失败不打断 toast */}
});
}
+/* ── 历史账单:手动从平台同步(按月),仅账单调口渠道(阿里云百炼)展示 ── */
+function billBlockHtml(ch){
+ const bills=ch.bills||[];
+ if(!(ch.platform==='阿里云百炼'&&ch.billing_model==='balance')) return '';
+ const rows=bills.map(b=>`
${b.month}¥${fmtMoney(b.total)}
`).join('');
+ const total=bills.reduce((s,b)=>s+Number(b.total),0);
+ const list=bills.length?rows+`合计¥${fmtMoney(total)}
`:'未同步 · 点上方按钮拉取平台账单
';
+ return ``;
+}
+async function syncBills(id){
+ const btn=document.getElementById('billSyncBtn'+id);
+ if(btn){btn.disabled=true;btn.textContent='同步中…';}
+ try{
+ const res=await fetch(`${API_BASE}/api/channels/${id}/sync-bills`,{method:'POST'});
+ const data=await res.json().catch(()=>({}));
+ if(!res.ok) throw new Error(data.message||'同步失败');
+ await loadChannels();
+ renderSidebar();
+ showToast(`历史账单同步完成:${data.synced} 个月,合计 ¥${fmtMoney(data.total)}`);
+ }catch(e){
+ showToast(e.message,true);
+ if(btn){btn.disabled=false;btn.textContent='⟳ 同步历史账单';}
+ }
+}
function renderSidebar(){
const list=document.getElementById('channelList');
list.innerHTML='';
@@ -556,7 +589,7 @@ function renderSidebar(){
div.className='ch-item';
div.title=ch.billing_model==='balance'?'点击此渠道立即手动采集一次(10 秒冷却)':'点击测试连接(订阅制数据由采集脚本推送)';
const checked=selectedChannels.has(ch.id)?'checked':'';
- div.innerHTML=`${esc(ch.name)}${esc(ch.platform||'')}${ch.billing_model==='balance'?'按量':'订阅'}
`;
+ div.innerHTML=`${esc(ch.name)}${esc(ch.platform||'')}${ch.billing_model==='balance'?'按量':'订阅'}
${billBlockHtml(ch)}`;
// 点击条目主体(排除 checkbox、⋮ 按钮与弹出菜单)触发手动采集
div.onclick=(ev)=>{
if(ev.target.closest('input,button,.ch-menu')) return;
diff --git a/token-meter/server.js b/token-meter/server.js
index ed08120..407b31b 100644
--- a/token-meter/server.js
+++ b/token-meter/server.js
@@ -268,6 +268,17 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
`);
+ // ── 历史账单表(手动同步的月度账单,供仪表盘按月回看真实费用)──
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS \`channel_bill_history\` (
+ \`channel_id\` INT NOT NULL,
+ \`month\` VARCHAR(7) NOT NULL,
+ \`total\` DECIMAL(24,10) NOT NULL DEFAULT 0,
+ \`synced_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (\`channel_id\`, \`month\`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+ `);
+
// ── 确保 ai_usage_points 有 channel_id 列 ──
const [cols] = await pool.query(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'ai_usage_points' AND COLUMN_NAME = 'channel_id'",
@@ -553,6 +564,16 @@ async function handleGetChannels(response) {
const usageMap = {};
for (const u of usageRows) usageMap[u.channel_id] = Number(u.total_usage) || 0;
+ // ── 批量查询各渠道的历史月度账单(手动同步)──
+ const [billRows] = await pool.query(
+ 'SELECT `channel_id`, `month`, `total` FROM `channel_bill_history` ORDER BY `month` ASC'
+ );
+ const billMap = {};
+ for (const b of billRows) {
+ if (!billMap[b.channel_id]) billMap[b.channel_id] = [];
+ billMap[b.channel_id].push({ month: b.month, total: Number(b.total) });
+ }
+
return sendJson(response, 200, {
channels: rows.map(r => ({
id: r.id,
@@ -566,7 +587,8 @@ async function handleGetChannels(response) {
spend_alert_threshold: r.spend_alert_threshold == null ? null : Number(r.spend_alert_threshold),
enabled: !!r.enabled,
created_at: r.created_at,
- total_usage: usageMap[r.id] || 0
+ total_usage: usageMap[r.id] || 0,
+ bills: billMap[r.id] || []
}))
});
}
@@ -763,9 +785,33 @@ async function fetchAlibabaBillTotal(accessKeyId, accessKeySecret, billingCycle)
return total;
}
-/** 百炼渠道每日账单校准:逐月累计「自监控起点」以来的真实消费,与余额差已用对比。
- * 偏差(如停机期间充值+消费同时发生、自动识别漏掉的注资)> 0 时补记到 topups;
- * 偏差 ≤ 0 一律忽略(当月账单未完整出账等都会让账单偏小,向下修正会污染已用曲线)。 */
+/** 手动同步历史账单:从当前月往前逐月查询 BSS 月度账单,写入 channel_bill_history 供仪表盘按月回看。
+ * 连续 2 个空月视为更早无账单即停止,最多回溯 36 个月;与每日校准(费用对账)相互独立。 */
+async function syncBillHistoryForChannel(channel) {
+ const { accessKeyId, accessKeySecret } = alibabaAccessKeyOf(channel);
+ const synced = [];
+ let emptyStreak = 0;
+ const now = new Date();
+ for (let i = 0; i < 36; i++) {
+ const cursor = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
+ const total = await fetchAlibabaBillTotal(accessKeyId, accessKeySecret, month);
+ if (total > 0.005) {
+ await pool.query(
+ 'INSERT INTO `channel_bill_history` (`channel_id`, `month`, `total`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `total` = VALUES(`total`)',
+ [channel.id, month, total]
+ );
+ synced.push({ month, total: +total.toFixed(6) });
+ emptyStreak = 0;
+ } else {
+ emptyStreak += 1;
+ if (emptyStreak >= 2) break;
+ }
+ await new Promise(resolve => setTimeout(resolve, 250)); // 限速避免接口限流
+ }
+ return synced;
+}
+
async function calibrateAlibabaChannels() {
const [rows] = await pool.query(
'SELECT `id`, `name`, `api_key`, `baseline_balance`, `topups` FROM `channels` WHERE `platform` = ? AND `enabled` = 1',
@@ -1219,6 +1265,27 @@ async function collectAllChannels() {
}
}
+// ── 手动同步历史账单:仅提供账单接口的渠道(阿里云百炼)可用,30 秒冷却防连点 ──
+const lastSyncBillsAt = new Map();
+async function handleSyncBills(response, idStr) {
+ const id = Number(idStr);
+ if (!id) throw new ApiError(400, '无效的渠道 ID。');
+ const [rows] = await pool.query('SELECT `id`, `name`, `platform`, `api_key` FROM `channels` WHERE `id` = ?', [id]);
+ if (!rows.length) throw new ApiError(404, '渠道未找到。');
+ const channel = rows[0];
+ if (channel.platform !== '阿里云百炼' || !channel.api_key) {
+ throw new ApiError(400, '该渠道不支持同步历史账单(需要阿里云百炼的 AccessKey)。');
+ }
+ const waitMs = 30000 - (Date.now() - (lastSyncBillsAt.get(id) || 0));
+ if (waitMs > 0) throw new ApiError(429, `同步过于频繁,请 ${Math.ceil(waitMs / 1000)} 秒后再试。`);
+ lastSyncBillsAt.set(id, Date.now());
+ log(`[历史账单] 渠道「${channel.name}」开始同步历史账单…`);
+ const synced = await syncBillHistoryForChannel(channel);
+ const total = synced.reduce((sum, m) => sum + m.total, 0);
+ log(`[历史账单] 渠道「${channel.name}」同步完成:${synced.length} 个月(合计 ¥${total.toFixed(2)})`);
+ return sendJson(response, 200, { ok: true, synced: synced.length, months: synced, total: +total.toFixed(6) });
+}
+
// ── 手动更新渠道:点击渠道条目时立即采集一次(10 秒冷却),落库按手动时间差决策 ──
async function handleManualCollect(response, idStr) {
const id = Number(idStr);
@@ -1328,6 +1395,8 @@ async function handleDeleteChannel(response, idStr) {
await connection.beginTransaction();
// 级联清理该渠道的历史用量数据,避免留下孤儿数据点
await connection.query('DELETE FROM `ai_usage_points` WHERE `channel_id` = ?', [id]);
+ // 级联清理该渠道的历史账单记录
+ await connection.query('DELETE FROM `channel_bill_history` WHERE `channel_id` = ?', [id]);
const [result] = await connection.query('DELETE FROM `channels` WHERE `id` = ?', [id]);
if (!result.affectedRows) throw new ApiError(404, '渠道未找到。');
await connection.commit();
@@ -1548,6 +1617,10 @@ async function handleRequest(request, response) {
if (request.method === 'POST' && channelCollectMatch) {
return handleManualCollect(response, channelCollectMatch[1]);
}
+ const channelSyncBillsMatch = url.pathname.match(/^\/api\/channels\/(\d+)\/sync-bills$/);
+ if (request.method === 'POST' && channelSyncBillsMatch) {
+ return handleSyncBills(response, channelSyncBillsMatch[1]);
+ }
// ── 用量查询 API ──
if (request.method === 'GET' && url.pathname === '/api/usage') {