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 `
历史账单
${list}
`; +} +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') {