历史账单手动同步:BSS 月度账单拉取与按月回看

This commit is contained in:
2026-08-31 12:21:56 +08:00
parent ae0b16b129
commit 1e2ad0a4af
2 changed files with 111 additions and 5 deletions
+34 -1
View File
@@ -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=>`<div class="bill-row"><span>${b.month}</span><span>¥${fmtMoney(b.total)}</span></div>`).join('');
const total=bills.reduce((s,b)=>s+Number(b.total),0);
const list=bills.length?rows+`<div class="bill-row bill-total"><span>合计</span><span>¥${fmtMoney(total)}</span></div>`:'<div class="bill-empty">未同步 · 点上方按钮拉取平台账单</div>';
return `<div class="ch-bills"><div class="ch-bills-head"><span class="bill-title">历史账单</span><button class="bill-sync-btn" id="billSyncBtn${ch.id}" onclick="syncBills(${ch.id})">⟳ 同步历史账单</button></div>${list}</div>`;
}
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=`<div class="ch-main"><input type="checkbox" ${checked} onchange="toggleChannel(${ch.id})"><span>${esc(ch.name)}</span><span class="ch-tag">${esc(ch.platform||'')}</span>${ch.billing_model==='balance'?'<span class="ch-tag mode-tag tag-balance">按量</span>':'<span class="ch-tag mode-tag tag-used">订阅</span>'}<span class="status-dot" id="chDot${ch.id}"></span></div><div class="ch-status" id="chStat${ch.id}"></div><div class="ch-source" id="chSrc${ch.id}"></div><button class="ch-more" onclick="toggleChannelMenu(${ch.id},event)" title="更多操作">⋮</button><div class="ch-menu" id="chMenu${ch.id}"><button onclick="openEditChannel(${ch.id},event)">✎ 修改</button><button class="danger" onclick="removeChannel(${ch.id},event)">🗑 删除</button></div>`;
div.innerHTML=`<div class="ch-main"><input type="checkbox" ${checked} onchange="toggleChannel(${ch.id})"><span>${esc(ch.name)}</span><span class="ch-tag">${esc(ch.platform||'')}</span>${ch.billing_model==='balance'?'<span class="ch-tag mode-tag tag-balance">按量</span>':'<span class="ch-tag mode-tag tag-used">订阅</span>'}<span class="status-dot" id="chDot${ch.id}"></span></div><div class="ch-status" id="chStat${ch.id}"></div><div class="ch-source" id="chSrc${ch.id}"></div>${billBlockHtml(ch)}<button class="ch-more" onclick="toggleChannelMenu(${ch.id},event)" title="更多操作">⋮</button><div class="ch-menu" id="chMenu${ch.id}"><button onclick="openEditChannel(${ch.id},event)">✎ 修改</button><button class="danger" onclick="removeChannel(${ch.id},event)">🗑 删除</button></div>`;
// 点击条目主体(排除 checkbox、⋮ 按钮与弹出菜单)触发手动采集
div.onclick=(ev)=>{
if(ev.target.closest('input,button,.ch-menu')) return;
+77 -4
View File
@@ -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') {