历史账单手动同步:BSS 月度账单拉取与按月回看
This commit is contained in:
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user