feat: 重构定投评估工具并优化Cloud工具体验

- 重构市场定投评估逻辑:优先读取本地估值快照,必要时通过探针补抓海外数据,后端接口新增force参数支持强制刷新,更新前后端注释与交互文案
- 优化Cloud工具全链路:
  - 新增运营商登录页地址字段,支持一键复制账号密码并打开对应登录页
  - 缓存密钥派生结果,避免重复阻塞事件循环,提升列表加载性能
  - 完善接口错误处理,新增面板级加载失败提示与自动重试功能
  - 修复云资源代理转发时的Cookie缺失问题
  - 并行加载所有面板数据,优化首屏渲染速度
  - 新增登录按钮样式与错误提示样式
- 新增4个Cloud工具调试脚本,用于性能测试、解密审计、回归验证与加载耗时统计
- 启动定投评估工具的定时调度任务
This commit is contained in:
yangxiangyuan
2026-08-17 16:33:41 +08:00
parent 4016636153
commit e4f84caa23
10 changed files with 1149 additions and 237 deletions
@@ -0,0 +1,53 @@
// 调试脚本:逐行审计 cloud ecs/vas 加密字段,区分"明文原本为空"与"解密失败"
// 用法:node dev_test_scripts/debug/debug_cloud_decrypt_audit.js
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const os = require('os')
// 与 index.js 相同的凭据加载逻辑
const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
const credsPath = path.join(userProfile, 'Toolbox_local_creds.env.local')
if (fs.existsSync(credsPath)) {
fs.readFileSync(credsPath, 'utf-8').split(/\r?\n/).forEach(line => {
const t = line.trim()
if (!t || t.startsWith('#')) return
const i = t.indexOf('=')
if (i <= 0) return
const k = t.slice(0, i).trim()
const v = t.slice(i + 1).trim()
if (k && !Object.prototype.hasOwnProperty.call(process.env, k)) process.env[k] = v
})
console.log('creds loaded from', credsPath)
} else {
console.log('WARN: creds file not found')
}
const { db } = require('../../src/server/db')
const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'config', 'cloud.json'), 'utf-8'))
const saltStr = String(cfg.machine_salt || '')
const passphrase = process.env.CLOUD_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
console.log('passphrase present:', !!passphrase)
const key = crypto.scryptSync(passphrase + saltStr, Buffer.from(saltStr || 'default', 'utf-8'), 32)
const audit = (table, ec, ivc, tagc) => {
const rows = db.prepare(`SELECT id, ${ec}, ${ivc}, ${tagc} FROM ${table}`).all()
let empty = 0
let fail = 0
let ok = 0
for (const r of rows) {
if (!r[ec] || !r[ivc] || !r[tagc]) continue
try {
const d = crypto.createDecipheriv('aes-256-gcm', key, r[ivc])
d.setAuthTag(r[tagc])
const pt = Buffer.concat([d.update(r[ec]), d.final()]).toString('utf-8')
if (pt === '') { empty++; console.log(`${table} id=${r.id}: 解密成功但明文为空(原本就没填)`) } else ok++
} catch (e) { fail++; console.log(`${table} id=${r.id}: 解密失败 ${String(e.message)}`) }
}
console.log(`${table}: ok_non_empty=${ok} empty_plaintext=${empty} auth_fail=${fail}`)
}
audit('cloud_operators', 'login_password_enc', 'login_password_iv', 'login_password_tag')
audit('cloud_ecs', 'rdp_password_enc', 'rdp_password_iv', 'rdp_password_tag')
audit('cloud_vas', 'access_text_enc', 'access_text_iv', 'access_text_tag')
console.log('audit done')
@@ -0,0 +1,80 @@
// 调试脚本:测量 cloud 工具初始化各接口耗时 + 拉取前端加载日志,定位刷新"顿一下"的卡点
// 用法:node dev_test_scripts/debug/debug_cloud_load_timing.js
const BASE = process.env.CLOUD_TIMING_BASE || 'http://localhost:8976'
const getCookieFromGo = async () => {
const r1 = await fetch(`${BASE}/go?systemId=Tools-cloud`, { redirect: 'manual' })
const loc = r1.headers.get('location')
if (!loc) throw new Error('no location from /go, status=' + r1.status)
const r2 = await fetch(new URL(loc, BASE).toString(), { redirect: 'manual' })
const setc = String(r2.headers.get('set-cookie') || '')
if (!/cloud_gate=1/.test(setc)) throw new Error('no cloud_gate cookie, status=' + r2.status + ' set-cookie=' + setc)
return 'cloud_gate=1'
}
const timeIt = async (name, fn) => {
const t0 = Date.now()
try {
const r = await fn()
await r.text()
return { name, ms: Date.now() - t0, status: r.status }
} catch (e) {
return { name, ms: Date.now() - t0, error: String((e && e.message) || e) }
}
}
const main = async () => {
const cookie = await getCookieFromGo()
const get = url => fetch(`${BASE}${url}`, { headers: { Cookie: cookie }, cache: 'no-store' })
const post = url => fetch(`${BASE}${url}`, { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'cloud', action: 'timing_probe' }) })
// 1) 拉取前端真实加载日志(用户刷新时上报的 start/done/error 序列)
try {
const logs = await (await get('/api/debug/logs?limit=150&channel=cloud')).json()
console.log('=== 最近 cloud 前端事件(旧→新) ===')
;(logs.lines || []).forEach(l => {
const o = typeof l === 'string' ? (() => { try { return JSON.parse(l) } catch { return { raw: l } } })() : l
console.log(`${o.ts || o.time || o.t || ''} | ${o.action || o.type || o.event || ''} | count=${o.count != null ? o.count : ''} ${o.error ? 'err=' + String(o.error).slice(0, 80) : ''}`)
})
} catch (e) { console.log('读取 debug logs 失败:', e.message) }
// 2) 顺序测量各接口(2 轮)
const endpoints = [
['GET /api/cloud/operators', () => get('/api/cloud/operators')],
['GET /api/cloud/ecs', () => get('/api/cloud/ecs')],
['GET /api/cloud/vas', () => get('/api/cloud/vas')],
['GET /api/cloud/renew', () => get('/api/cloud/renew')],
['GET /api/cloud/notes', () => get('/api/cloud/notes')],
['GET /api/cloud/balance_alerts/status', () => get('/api/cloud/balance_alerts/status')],
['POST /api/debug/event', () => post('/api/debug/event')],
['GET /api/flags', () => get('/api/flags')]
]
for (let round = 1; round <= 2; round++) {
console.log(`=== 顺序测量 第${round}轮 ===`)
for (const [name, fn] of endpoints) {
const r = await timeIt(name, fn)
console.log(`${String(r.ms).padStart(5)}ms ${r.status || ''} ${r.error || ''} ${name}`)
}
}
// 3) 模拟浏览器初始化并发突发(2 轮)
for (let round = 1; round <= 2; round++) {
console.log(`=== 并发突发 第${round}轮 ===`)
const t0 = Date.now()
const results = await Promise.all([
timeIt('POST boot', () => post('/api/debug/event')),
timeIt('POST ops_start', () => post('/api/debug/event')),
timeIt('GET operators', () => get('/api/cloud/operators')),
timeIt('GET ecs', () => get('/api/cloud/ecs')),
timeIt('GET vas', () => get('/api/cloud/vas')),
timeIt('GET renew', () => get('/api/cloud/renew')),
timeIt('GET notes', () => get('/api/cloud/notes')),
timeIt('GET balance_status', () => get('/api/cloud/balance_alerts/status')),
timeIt('GET flags', () => get('/api/flags'))
])
results.sort((a, b) => a.ms - b.ms).forEach(r => console.log(`${String(r.ms).padStart(5)}ms ${r.status || ''} ${r.error || ''} ${r.name}`))
console.log(`突发总耗时: ${Date.now() - t0}ms`)
}
}
main().catch(e => { console.error('FATAL', e); process.exit(1) })
@@ -0,0 +1,46 @@
// 调试脚本:cloud 重启后鉴权链路 + 解密数据完整性回归
// 用法:node dev_test_scripts/debug/debug_cloud_regression.js
const BASE = 'http://localhost:8976'
const main = async () => {
const r1 = await fetch(BASE + '/go?systemId=Tools-cloud', { redirect: 'manual' })
console.log('entry:', r1.status, r1.headers.get('location') ? '(redirect ok)' : '(NO LOCATION)')
const r2 = await fetch(new URL(r1.headers.get('location'), BASE), { redirect: 'manual' })
const setc = String(r2.headers.get('set-cookie') || '')
console.log('index hop:', r2.status, /cloud_gate=1/.test(setc) ? '(cookie ok)' : '(NO COOKIE)')
const r3 = await fetch(BASE + '/tools/cloud/index.html', { headers: { Cookie: 'cloud_gate=1' } })
console.log('index.html:', r3.status)
const r4 = await fetch(BASE + '/tools/cloud/cloud.js', { headers: { Cookie: 'cloud_gate=1' } })
console.log('cloud.js:', r4.status)
const H = { Cookie: 'cloud_gate=1' }
const j = async u => (await fetch(BASE + u, { headers: H })).json()
const ops = await j('/api/cloud/operators')
const opsEnc = (ops.rows || []).filter(r => r.login_password_enc)
const opsDecOk = opsEnc.filter(r => String(r.login_password || '') !== '').length
console.log(`operators: ok=${ops.ok} rows=${(ops.rows || []).length} enc=${opsEnc.length} decrypted_non_empty=${opsDecOk}`)
const ecs = await j('/api/cloud/ecs')
const ecsEnc = (ecs.rows || []).filter(r => r.rdp_password_enc)
const ecsDecOk = ecsEnc.filter(r => String(r.rdp_password || '') !== '').length
console.log(`ecs: ok=${ecs.ok} rows=${(ecs.rows || []).length} enc=${ecsEnc.length} decrypted_non_empty=${ecsDecOk}`)
const vas = await j('/api/cloud/vas')
const vasEnc = (vas.rows || []).filter(r => r.access_text_enc)
const vasDecOk = vasEnc.filter(r => String(r.access_text || '') !== '').length
console.log(`vas: ok=${vas.ok} rows=${(vas.rows || []).length} enc=${vasEnc.length} decrypted_non_empty=${vasDecOk}`)
const rn = await j('/api/cloud/renew')
console.log(`renew: ok=${rn.ok} rows=${(rn.rows || []).length}`)
const nt = await j('/api/cloud/notes')
console.log(`notes: ok=${nt.ok} rows=${(nt.rows || []).length}`)
const bs = await j('/api/cloud/balance_alerts/status')
console.log(`balance status: ok=${bs.ok} proxied=${!!bs.proxied_to_automation} listener=${bs.data && bs.data.listener ? 'present' : 'n/a'}`)
const bad = (opsEnc.length !== opsDecOk) || (ecsEnc.length !== ecsDecOk) || (vasEnc.length !== vasDecOk)
console.log(bad ? 'REGRESSION FAIL: 存在解密为空的行' : 'REGRESSION PASS')
process.exit(bad ? 1 : 0)
}
main().catch(e => { console.error('ERR', e.message); process.exit(1) })
@@ -0,0 +1,29 @@
// 调试脚本:对指定 BASE 做 cloud 接口顺序+并发耗时复测
// 用法:node dev_test_scripts/debug/debug_cloud_timing_probe.js [base_url]
const BASE = process.argv[2] || 'http://localhost:8976'
const main = async () => {
const r1 = await fetch(BASE + '/go?systemId=Tools-cloud', { redirect: 'manual' })
const loc = r1.headers.get('location')
if (!loc) throw new Error('no location, status=' + r1.status)
await fetch(new URL(loc, BASE), { redirect: 'manual' })
const H = { Cookie: 'cloud_gate=1' }
const t = async n => {
const t0 = Date.now()
const r = await fetch(BASE + n, { headers: H })
await r.text()
return `${String(Date.now() - t0).padStart(5)}ms ${r.status} ${n}`
}
console.log('-- sequential --')
for (const u of ['/api/cloud/operators', '/api/cloud/ecs', '/api/cloud/vas', '/api/cloud/renew', '/api/cloud/notes', '/api/cloud/balance_alerts/status']) {
console.log(await t(u))
}
console.log('-- burst --')
const t0 = Date.now()
const urls = ['/api/cloud/operators', '/api/cloud/ecs', '/api/cloud/vas', '/api/cloud/renew', '/api/cloud/notes', '/api/cloud/balance_alerts/status', '/api/flags']
const rs = await Promise.all(urls.map(u => fetch(BASE + u, { headers: H }).then(async r => { await r.text(); return `${String(Date.now() - t0).padStart(5)}ms ${r.status} ${u}` })))
rs.forEach(x => console.log(x))
console.log('burst total', Date.now() - t0, 'ms')
}
main().catch(e => { console.error('ERR', e.message); process.exit(1) })
+12
View File
@@ -44,6 +44,8 @@ h2 { margin: 12px 0 8px }
.btn { background: var(--cloud-accent); color: #fff; border: none; border-radius: 8px; padding: 6px 12px; cursor: pointer }
.btn:hover { background: #1f5a88 }
.status { background: #fff8e1; border: 1px solid #ffe08a; color: #7a5900; border-radius: 8px; padding: 6px 10px; margin: 8px 0 }
.cd-load-error { display: flex; align-items: center; gap: 12px; margin: 8px 0; padding: 8px 12px; border: 1px solid #e5a4a4; background: #fdecec; color: #a33; border-radius: 8px; font-size: 13px }
body.theme-cloud[data-theme="dark"] .cd-load-error { background: #3a1d1d; border-color: #7a4444; color: #f0b9b9 }
/* Note specific styles */
.small-text-area { font-size: 10px !important; line-height: 1.2; font-family: inherit; }
@@ -316,6 +318,16 @@ body.theme-cloud .panel.card { position: relative; }
color: white;
}
.action-btn.login {
border-color: #6f42c1;
color: #6f42c1;
}
.action-btn.login:hover {
background: #6f42c1;
color: white;
}
/* 列表操作列内按钮圆形化 */
.grid td.col-actions .action-btn {
width: 28px;
+121 -16
View File
@@ -1,9 +1,107 @@
const out = () => document.getElementById('cd-output')
const logLocal = msg => { const b = out(); if (!b) return; const t = window.fmtCNDateTime?fmtCNDateTime(new Date()):new Date().toLocaleString('zh-CN'); b.textContent = (b.textContent||'') + (b.textContent?"\n":"") + `${t} ${msg}`; b.scrollTop = b.scrollHeight }
// 常用运营商登录页地址(按运营商名称关键字匹配;记录里已填 login_url 时优先)
const QUICK_LOGIN_URL_TABLE = [
{ match: /阿里/, url: 'https://account.aliyun.com/login/login.htm' },
{ match: /racknerd/i, url: 'https://my.racknerd.com/clientarea.php' },
{ match: /namecheap/i, url: 'https://www.namecheap.com/myaccount/login/' },
{ match: /华为/, url: 'https://auth.huaweicloud.com/authui/login.html' }
]
const quickLoginUrlOf = name => {
const n = String(name || '')
for (const item of QUICK_LOGIN_URL_TABLE) {
if (item.match.test(n)) return item.url
}
return ''
}
// 一键登录:复制「账号<Tab>密码」到剪贴板,并打开运营商登录页
async function quickLoginOperator(row) {
if (!row) return
const account = String(row.login_account || '').trim()
const password = String(row.login_password || '')
const loginUrl = String(row.login_url || '').trim() || quickLoginUrlOf(row.name)
if (!account) { showStatus('该运营商未填写登录账号'); hideStatusSoon(); return }
const text = `${account}\t${password}`
try {
await navigator.clipboard.writeText(text)
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
} catch { showStatus('复制失败,请手动复制'); hideStatusSoon(); return }
}
showStatus(`账号密码已复制到剪贴板,正在打开 ${row.name} 登录页…`)
if (loginUrl) {
window.open(loginUrl, '_blank', 'noopener')
} else {
showStatus(`账号密码已复制。未配置 ${row.name} 登录页地址,请手动打开登录页后再粘贴(账号框内 Ctrl+V)`)
}
hideStatusSoon()
}
const showStatus = s => { const el = document.getElementById('status'); if (!el) return; el.style.display=''; el.textContent=s }
const hideStatusSoon = () => { const el = document.getElementById('status'); if (!el) return; setTimeout(()=>{ el.style.display='none' }, 1200) }
// 健壮 JSON 加载器:识别 HTML 错误页(IIS 502/服务重启)与未登录,给出可读错误
const fetchJson = async url => {
const r = await fetch(url, { credentials: 'include', cache: 'no-store' })
const text = await r.text()
let d = null
try { d = JSON.parse(text) } catch {}
if (d && typeof d === 'object') {
if (d.error === 'unauthorized') { const e = new Error('未登录或登录已过期,请从首页九宫格重新进入'); e.noRetry = true; throw e }
return d
}
const isHtml = /<!DOCTYPE|<html/i.test(String(text).slice(0, 200))
throw new Error(isHtml ? `收到HTML错误页(HTTP ${r.status},服务可能正在重启)` : `非JSON响应(HTTP ${r.status})`)
}
// 失败自动重试(noRetry 错误不重试)
const withRetry = async fn => {
const delays = [800, 2000, 4000]
let lastErr = null
for (let i = 0; i <= delays.length; i++) {
try { return await fn() } catch (e) {
lastErr = e
if (e && e.noRetry) throw e
if (i < delays.length) await new Promise(res => setTimeout(res, delays[i]))
}
}
throw lastErr
}
// 面板级失败横幅:不再静默空表,提供一键重试
const clearPanelError = panelId => { const old = document.getElementById(`${panelId}-load-error`); if (old) old.remove() }
const showPanelError = (panelId, label, err, retryFn) => {
const pn = document.getElementById(panelId)
if (!pn) return
clearPanelError(panelId)
const div = document.createElement('div')
div.id = `${panelId}-load-error`
div.className = 'cd-load-error'
const msg = document.createElement('span')
msg.textContent = `${label}加载失败:${String(err && err.message || err)}(已自动重试多次)`
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'modal-btn secondary'
btn.textContent = '重试'
btn.addEventListener('click', async () => {
btn.disabled = true
btn.textContent = '重试中…'
try { await retryFn() } finally { if (document.getElementById(div.id)) { btn.disabled = false; btn.textContent = '重试' } }
})
div.appendChild(msg)
div.appendChild(btn)
const anchor = pn.querySelector('.list-card') || pn.querySelector('table') || pn.firstChild
pn.insertBefore(div, anchor)
}
const loadFlags = async () => { try { const r = await fetch('/api/flags'); const d = await r.json(); if (d && d.debug && d.debug.cloud) { const sec = document.getElementById('cloud-debug'); if (sec) sec.style.display = '' } } catch {}
}
@@ -69,6 +167,8 @@ let opMap = {}
let opsCache = []
let ecsCache = []
let vasCache = []
let renewCache = []
let notesCache = []
let editingOpId = null
let editingEcsId = null
let editingVasId = null
@@ -80,25 +180,25 @@ let balanceListState = { page: 1, pageSize: 10, keyword: '' }
let balanceStatusCache = null
let balanceAutoRefreshTimer = null
let balanceScanTrace = []
const renderOps = rows => { const body = document.getElementById('ops-body'); if (!body) return; body.innerHTML=''; rows.forEach(r => { opMap[r.id] = r.name || (`#${r.id}`); const tr = document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.name; const td2=document.createElement('td'); td2.textContent=r.login_account||''; const td3=document.createElement('td'); td3.textContent=r.login_password||''; const td4=document.createElement('td'); td4.textContent=r.mobile||''; const td5=document.createElement('td'); td5.textContent=r.contact_person||''; const td6=createExpandableCell(r.remark||'', 'remark-text'); const td7=document.createElement('td'); td7.className='col-cert'; const badge=document.createElement('span'); badge.className = r.is_company_verified ? 'badge success' : 'badge'; badge.textContent = r.is_company_verified ? '已认证' : '未认证'; td7.appendChild(badge); const td8=document.createElement('td'); td8.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ops', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ops', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除运营商?')) return; await fetch(`/api/cloud/operators/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadOps() }); td8.appendChild(view); td8.appendChild(edit); td8.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); body.appendChild(tr) }) }
const renderOps = rows => { const body = document.getElementById('ops-body'); if (!body) return; body.innerHTML=''; rows.forEach(r => { opMap[r.id] = r.name || (`#${r.id}`); const tr = document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.name; const td2=document.createElement('td'); td2.textContent=r.login_account||''; const td3=document.createElement('td'); td3.textContent=r.login_password||''; const td4=document.createElement('td'); td4.textContent=r.mobile||''; const td5=document.createElement('td'); td5.textContent=r.contact_person||''; const td6=createExpandableCell(r.remark||'', 'remark-text'); const td7=document.createElement('td'); td7.className='col-cert'; const badge=document.createElement('span'); badge.className = r.is_company_verified ? 'badge success' : 'badge'; badge.textContent = r.is_company_verified ? '已认证' : '未认证'; td7.appendChild(badge); const td8=document.createElement('td'); td8.className='col-actions'; const login=document.createElement('button'); login.className='action-btn login'; login.title='一键登录(复制账号密码并打开登录页)'; login.textContent='🔐'; login.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); quickLoginOperator({ id:r.id, name:r.name, login_account:r.login_account, login_password:r.login_password, login_url:r.login_url||'' }) }); const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ops', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ops', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除运营商?')) return; await fetch(`/api/cloud/operators/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadOps() }); td8.appendChild(login); td8.appendChild(view); td8.appendChild(edit); td8.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); body.appendChild(tr) }) }
const setOpOptions = rows => { const fill = id => { const el = document.getElementById(id); if (!el) return; el.innerHTML=''; const ph=document.createElement('option'); ph.value=''; ph.textContent='请选择运营商'; el.appendChild(ph); rows.forEach(r=>{ const op=document.createElement('option'); op.value=String(r.id); op.textContent=r.name||(`#${r.id}`); el.appendChild(op) }) }; fill('ecs-op'); fill('vas-op'); opMap = {}; rows.forEach(r=>{ opMap[r.id] = r.name||(`#${r.id}`) }) }
const loadOps = async () => { showStatus('正在查询运营商...'); try { await logEvent({ action:'ops_load_start' }); const r = await fetch('/api/cloud/operators', { credentials: 'include' }); const d = await r.json(); console.log('运营商数据:', d); if (d.ok) { opsCache = d.rows || []; renderOps(d.rows); setOpOptions(d.rows); await logEvent({ action:'ops_load_done', count:(d.rows||[]).length }) } else { console.error('运营商数据返回错误:', d); await logEvent({ action:'ops_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载运营商数据失败:', e); await logEvent({ action:'ops_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const loadOps = async () => { showStatus('正在查询运营商...'); try { await logEvent({ action:'ops_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/operators')); if (d.ok) { opsCache = d.rows || []; renderOps(opsCache); setOpOptions(opsCache); clearPanelError('panel-ops'); await logEvent({ action:'ops_load_done', count: opsCache.length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载运营商数据失败:', e); showPanelError('panel-ops', '运营商', e, loadOps); await logEvent({ action:'ops_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const addOp = async () => { try { const name=document.getElementById('op-name').value.trim(); const login_account=document.getElementById('op-account').value.trim(); const login_password=document.getElementById('op-password').value.trim(); const mobile=document.getElementById('op-mobile').value.trim(); const contact_person=document.getElementById('op-contact').value.trim(); const remark=(document.getElementById('op-remark')&&document.getElementById('op-remark').value)||''; const is_company_verified=document.getElementById('op-verified').checked?1:0; if (!name) { showStatus('请填写名称'); hideStatusSoon(); return } const resp = await fetch('/api/cloud/operators', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ name, login_account, login_password, mobile, contact_person, remark, is_company_verified }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadOps() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
const saveOp = async () => { try { if (!editingOpId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const name=document.getElementById('op-name').value.trim(); const login_account=document.getElementById('op-account').value.trim(); const login_password=document.getElementById('op-password').value.trim(); const mobile=document.getElementById('op-mobile').value.trim(); const contact_person=document.getElementById('op-contact').value.trim(); const remark=(document.getElementById('op-remark')&&document.getElementById('op-remark').value)||''; const is_company_verified=document.getElementById('op-verified').checked?1:0; if (!name) { showStatus('请填写名称'); hideStatusSoon(); return } const resp = await fetch(`/api/cloud/operators/${editingOpId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingOpId, name, login_account, login_password, mobile, contact_person, remark, is_company_verified }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingOpId=null; showStatus('保存成功'); hideStatusSoon(); await loadOps() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
const renderEcs = rows => { ecsCache = rows || []; const body=document.getElementById('ecs-body'); if (!body) return; body.innerHTML=''; ecsCache.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.id; const td2=document.createElement('td'); td2.textContent=opMap[r.operator_id]||String(r.operator_id||''); const td2b=document.createElement('td'); td2b.textContent=r.server_name||''; const td3=document.createElement('td'); td3.textContent=r.region||''; const td4=document.createElement('td'); td4.textContent=r.zone||''; const td5=document.createElement('td'); td5.textContent=r.rdp_ip||''; const td6=document.createElement('td'); td6.textContent=r.rdp_user||''; const td7=document.createElement('td'); td7.textContent=r.rdp_password||''; const td8=createExpandableCell(r.spec_text||'', 'spec-text'); const td9=createExpandableCell(r.open_ports_text||'', 'ports-text'); const td10=document.createElement('td'); td10.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ecs', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ecs', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除服务器?')) return; await fetch(`/api/cloud/ecs/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadEcs() }); td10.appendChild(view); td10.appendChild(edit); td10.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td2b); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); tr.appendChild(td9); tr.appendChild(td10); body.appendChild(tr) }) }
const loadEcs = async () => { showStatus('正在查询服务器...'); try { await logEvent({ action:'ecs_load_start' }); const r = await fetch('/api/cloud/ecs', { credentials: 'include' }); const d = await r.json(); console.log('ECS数据:', d); if (d.ok) { renderEcs(d.rows); await logEvent({ action:'ecs_load_done', count:(d.rows||[]).length }) } else { console.error('ECS返回错误:', d); await logEvent({ action:'ecs_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载ECS失败:', e); await logEvent({ action:'ecs_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const loadEcs = async () => { showStatus('正在查询服务器...'); try { await logEvent({ action:'ecs_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/ecs')); if (d.ok) { renderEcs(d.rows || []); clearPanelError('panel-ecs'); await logEvent({ action:'ecs_load_done', count:(d.rows||[]).length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载ECS失败:', e); showPanelError('panel-ecs', 'ECS服务器', e, loadEcs); await logEvent({ action:'ecs_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const addEcs = async () => { try { const operator_id=parseInt(document.getElementById('ecs-op').value||'0',10); const server_name=document.getElementById('ecs-name').value||''; const spec_text=document.getElementById('ecs-spec').value||''; const region=document.getElementById('ecs-region').value||''; const zone=document.getElementById('ecs-zone').value||''; const rdp_ip=document.getElementById('ecs-ip').value||''; const rdp_user=document.getElementById('ecs-user').value||''; const rdp_password=document.getElementById('ecs-pass').value||''; const open_ports_text=document.getElementById('ecs-ports').value||''; if (!operator_id) { showStatus('请选择运营商'); hideStatusSoon(); return } const resp = await fetch('/api/cloud/ecs', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadEcs() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
const saveEcs = async () => { try { if (!editingEcsId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const operator_id=parseInt(document.getElementById('ecs-op').value||'0',10); const server_name=document.getElementById('ecs-name').value||''; const spec_text=document.getElementById('ecs-spec').value||''; const region=document.getElementById('ecs-region').value||''; const zone=document.getElementById('ecs-zone').value||''; const rdp_ip=document.getElementById('ecs-ip').value||''; const rdp_user=document.getElementById('ecs-user').value||''; const rdp_password=document.getElementById('ecs-pass').value||''; const open_ports_text=document.getElementById('ecs-ports').value||''; if (!operator_id) { showStatus('请选择运营商'); hideStatusSoon(); return } const resp = await fetch(`/api/cloud/ecs/${editingEcsId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingEcsId, operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingEcsId=null; showStatus('保存成功'); hideStatusSoon(); await loadEcs() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
const renderVas = rows => { vasCache = rows || []; const body=document.getElementById('vas-body'); if (!body) return; body.innerHTML=''; vasCache.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.id; const td2=document.createElement('td'); td2.textContent=opMap[r.operator_id]||String(r.operator_id||''); const td3=document.createElement('td'); td3.textContent=r.name||''; const td4=createExpandableCell(r.config_text||'', 'vas-config'); const td5=createExpandableCell(r.access_text||'', 'vas-access'); const td6=document.createElement('td'); td6.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('vas', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('vas', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除增值服务?')) return; await fetch(`/api/cloud/vas/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadVas() }); td6.appendChild(view); td6.appendChild(edit); td6.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); body.appendChild(tr) }) }
const loadVas = async () => { showStatus('正在查询增值服务...'); try { await logEvent({ action:'vas_load_start' }); const r = await fetch('/api/cloud/vas', { credentials: 'include' }); const d = await r.json(); console.log('增值服务数据:', d); if (d.ok) { renderVas(d.rows); await logEvent({ action:'vas_load_done', count:(d.rows||[]).length }) } else { console.error('增值服务返回错误:', d); await logEvent({ action:'vas_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载增值服务失败:', e); await logEvent({ action:'vas_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const loadVas = async () => { showStatus('正在查询增值服务...'); try { await logEvent({ action:'vas_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/vas')); if (d.ok) { renderVas(d.rows || []); clearPanelError('panel-vas'); await logEvent({ action:'vas_load_done', count:(d.rows||[]).length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载增值服务失败:', e); showPanelError('panel-vas', '增值服务', e, loadVas); await logEvent({ action:'vas_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() }
const addVas = async () => { const operator_id=parseInt(document.getElementById('vas-op').value||'0',10); const name=document.getElementById('vas-name').value||''; const config_text=document.getElementById('vas-config').value||''; const access_text=document.getElementById('vas-access').value||''; if (!operator_id || !name) { showStatus('请选择运营商并填写名称'); hideStatusSoon(); return } await fetch('/api/cloud/vas', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ operator_id, name, config_text, access_text }) }); showStatus('新增成功'); hideStatusSoon(); await loadVas() }
const saveVas = async () => { if (!editingVasId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const operator_id=parseInt(document.getElementById('vas-op').value||'0',10); const name=document.getElementById('vas-name').value||''; const config_text=document.getElementById('vas-config').value||''; const access_text=document.getElementById('vas-access').value||''; if (!operator_id || !name) { showStatus('请选择运营商并填写名称'); hideStatusSoon(); return } await fetch(`/api/cloud/vas/${editingVasId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingVasId, operator_id, name, config_text, access_text }) }); editingVasId=null; showStatus('保存成功'); hideStatusSoon(); await loadVas() }
const findSubjectName = (type, id) => { if (type==='ecs') { const t=ecsCache.find(x=>x.id===id); if (t) return `${t.server_name||''}` } else if (type==='vas') { const v=vasCache.find(x=>x.id===id); if (v) return `${v.name||''}` } return '' }
const renderRenew = rows => { const body=document.getElementById('rn-body'); if (!body) return; body.innerHTML=''; rows.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=(r.subject_type==='ecs'?'ECS':'增值服务'); const td2=document.createElement('td'); td2.className='rn-text'; td2.textContent=findSubjectName(r.subject_type, r.subject_id)||''; const td3=document.createElement('td'); td3.textContent=r.subject_id; const td4=document.createElement('td'); td4.textContent=r.paid_date||''; const td5=document.createElement('td'); td5.textContent=fmtCents(r.amount_cents); const td6=document.createElement('td'); td6.className='rn-text'; if (r.invoice_path) { const a=document.createElement('a'); a.href=r.invoice_path; a.textContent='下载'; a.target='_blank'; td6.appendChild(a) } else { td6.textContent='' } const td7=document.createElement('td'); td7.textContent=r.payer_company||''; const td8=document.createElement('td'); td8.textContent=r.expire_date||''; const td9=document.createElement('td'); td9.textContent=fmtCents(r.next_expected_amount_cents||0); const td10=document.createElement('td'); td10.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('renew', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('renew', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除续费记录?')) return; await fetch(`/api/cloud/renew/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadRenew() }); td10.appendChild(view); td10.appendChild(edit); td10.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); tr.appendChild(td9); tr.appendChild(td10); body.appendChild(tr) }) }
const loadRenew = async () => { showStatus('正在查询续费情况...'); const r = await fetch('/api/cloud/renew', { credentials: 'include' }); const d = await r.json(); if (d.ok) renderRenew(d.rows); hideStatusSoon() }
const loadRenew = async () => { showStatus('正在查询续费情况...'); try { const d = await withRetry(() => fetchJson('/api/cloud/renew')); if (d.ok) { renewCache = d.rows || []; renderRenew(renewCache); clearPanelError('panel-renew') } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载续费情况失败:', e); showPanelError('panel-renew', '续费情况', e, loadRenew) } hideStatusSoon() }
const addRenew = async () => { try { const subject_type=document.getElementById('rn-type').value; const subject_id=parseInt(document.getElementById('rn-subject').value||'0',10); const paid_date=document.getElementById('rn-paid').value; const amount=parseFloat(document.getElementById('rn-amount').value||'0'); const amount_cents=Math.round(amount*100); let invoice_path=document.getElementById('rn-invoice').value||''; const payer_company=document.getElementById('rn-payer').value||''; const expire_date=document.getElementById('rn-expire').value||''; const next=parseFloat(document.getElementById('rn-next').value||'0'); const next_expected_amount_cents=Math.round(next*100); if (!subject_id || !paid_date || !amount_cents) { showStatus('请完整选择主体、填写缴费日期与金额'); hideStatusSoon(); return } const fEl=document.getElementById('rn-invoice-file'); if (fEl && fEl.files && fEl.files[0]) { try { const file=fEl.files[0]; const buf=await file.arrayBuffer(); const rr=await fetch(`/api/cloud/renew/invoice?filename=${encodeURIComponent(file.name)}`, { method:'POST', headers:{'Content-Type':'application/octet-stream'}, credentials: 'include', body: buf }); const dd=await rr.json(); if (dd && dd.ok && dd.path) invoice_path=dd.path } catch {} } const resp = await fetch('/api/cloud/renew', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadRenew() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
const saveRenew = async () => { try { if (!editingRnId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const subject_type=document.getElementById('rn-type').value; const subject_id=parseInt(document.getElementById('rn-subject').value||'0',10); const paid_date=document.getElementById('rn-paid').value; const amount=parseFloat(document.getElementById('rn-amount').value||'0'); const amount_cents=Math.round(amount*100); let invoice_path=document.getElementById('rn-invoice').value||''; const payer_company=document.getElementById('rn-payer').value||''; const expire_date=document.getElementById('rn-expire').value||''; const next=parseFloat(document.getElementById('rn-next').value||'0'); const next_expected_amount_cents=Math.round(next*100); if (!subject_id || !paid_date || !amount_cents) { showStatus('请完整选择主体、填写缴费日期与金额'); hideStatusSoon(); return } const fEl=document.getElementById('rn-invoice-file'); if (fEl && fEl.files && fEl.files[0]) { try { const file=fEl.files[0]; const buf=await file.arrayBuffer(); const rr=await fetch(`/api/cloud/renew/invoice?filename=${encodeURIComponent(file.name)}`, { method:'POST', headers:{'Content-Type':'application/octet-stream'}, credentials: 'include', body: buf }); const dd=await rr.json(); if (dd && dd.ok && dd.path) invoice_path=dd.path } catch {} } const resp = await fetch(`/api/cloud/renew/${editingRnId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingRnId, subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingRnId=null; showStatus('保存成功'); hideStatusSoon(); await loadRenew() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } }
@@ -403,11 +503,11 @@ const loadNotes = async (silent = false) => {
const shouldShowStatus = !silent && currentTab === 'notes'
if (shouldShowStatus) showStatus('正在查询学习笔记...')
try {
const r = await fetch('/api/cloud/notes', { credentials: 'include' });
const d = await r.json();
if (d.ok) renderNotes(d.rows);
const d = await withRetry(() => fetchJson('/api/cloud/notes'))
if (d.ok) { notesCache = d.rows || []; renderNotes(notesCache); clearPanelError('panel-notes') } else { throw new Error(d.error || '接口返回错误') }
} catch (e) {
console.error(e);
console.error(e)
showPanelError('panel-notes', '学习笔记', e, () => loadNotes(false))
}
if (shouldShowStatus) hideStatusSoon()
};
@@ -645,9 +745,10 @@ const renderBalanceAlerts = data => {
}
const loadBalanceStatus = async () => {
const r = await fetch('/api/cloud/balance_alerts/status', { credentials: 'include', cache: 'no-store' })
const d = await r.json()
try {
const d = await fetchJson('/api/cloud/balance_alerts/status')
if (d.ok) renderBalanceStatus(d.data)
} catch (e) { console.error(e) }
}
const loadBalanceAlerts = async (showLoading = true) => {
@@ -1048,11 +1149,9 @@ const initCloud = async () => {
const brf=document.getElementById('cd-btn-refresh'); if (brf) brf.addEventListener('click', async ()=>{ try { const r=await fetch('/api/debug/logs?limit=5000&channel=cloud'); const d=await r.json(); if (d.ok) { out().textContent=(d.lines||[]).join('\n'); out().scrollTop=out().scrollHeight } } catch {} })
const bcl=document.getElementById('cd-btn-clear'); if (bcl) bcl.addEventListener('click', async ()=>{ try { await fetch('/api/debug/clear?channel=cloud', { method:'POST' }) } catch {}; out().textContent='' })
await logEvent({ action:'boot' });
await loadOps();
await Promise.all([loadEcs(), loadVas()]);
await loadRenew();
await loadNotes();
await loadBalanceStatus();
await Promise.all([loadOps(), loadEcs(), loadVas(), loadRenew(), loadNotes(), loadBalanceStatus()]);
// 并行加载全部完成后统一重渲染一次,补齐运营商/主体等关联名称
renderEcs(ecsCache); renderVas(vasCache); renderRenew(renewCache); renderNotes(notesCache);
applyGridFontMode();
} catch (e) {
try { fetch('/api/debug/event', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ source:'cloud', action:'init_error', error:String(e&&e.message||e) }) }) } catch {}
@@ -1190,6 +1289,10 @@ function generateModalForm(type, action, id) {
<label class="modal-label">登录密码:</label>
<input type="text" class="modal-input" id="modal-op-password" placeholder="登录密码" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">登录页地址:</label>
<input type="text" class="modal-input" id="modal-op-login-url" placeholder="https://…(点🔐一键登录时优先用它打开)" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">关联手机:</label>
<input type="tel" class="modal-input" id="modal-op-mobile" placeholder="关联手机" ${readonly}>
@@ -1416,6 +1519,7 @@ function fillModalForm(type, data) {
document.getElementById('modal-op-name').value = data.name || '';
document.getElementById('modal-op-account').value = data.login_account || '';
document.getElementById('modal-op-password').value = data.login_password || '';
document.getElementById('modal-op-login-url').value = data.login_url || '';
document.getElementById('modal-op-mobile').value = data.mobile || '';
document.getElementById('modal-op-contact').value = data.contact_person || '';
document.getElementById('modal-op-remark').value = data.remark || '';
@@ -1571,6 +1675,7 @@ function getModalFormData(type) {
name: document.getElementById('modal-op-name').value.trim(),
login_account: document.getElementById('modal-op-account').value.trim(),
login_password: document.getElementById('modal-op-password').value.trim(),
login_url: document.getElementById('modal-op-login-url').value.trim(),
mobile: document.getElementById('modal-op-mobile').value.trim(),
contact_person: document.getElementById('modal-op-contact').value.trim(),
remark: document.getElementById('modal-op-remark').value.trim(),
+3 -3
View File
@@ -82,14 +82,14 @@ const runDipEval = async () => {
try {
const data = await runTrackedInteraction({
title: '建行定投评估',
action: '抓取四大指数 PE-TTM 与近10年分位并判档',
summary: '按需抓取:中证指数官网 / 蛋卷基金(备源 multpl.com)...',
action: '读取本地估值快照并判档,必要时通过探针补抓海外数据',
summary: 'A股走中证指数官网;标普走 multpl / DQYDJ 探针;纳指走 Yahoo+QQQ 探针自算,蛋卷兜底。',
button: btn,
loadingText: '评估中…',
request: { url: '/api/markets/dip_eval', method: 'GET' },
successText: d => `评估完成:${(d && Array.isArray(d.rows)) ? d.rows.length : 0} 个指数,${((d && d.errors) || []).length} 条异常`
}, async ctx => {
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '按需抓取,无定时任务' })
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '优先读取本地快照,必要时补抓海外探针数据' })
})
renderDipReport(data || {})
} catch (e) {
+16 -4
View File
@@ -8,11 +8,21 @@ const cfgPath = path.join(process.cwd(), 'config', 'cloud.json')
if (!fs.existsSync(path.dirname(cfgPath))) fs.mkdirSync(path.dirname(cfgPath), { recursive: true })
if (!fs.existsSync(cfgPath)) fs.writeFileSync(cfgPath, JSON.stringify({ machine_salt: crypto.randomBytes(16).toString('hex') }, null, 2))
const getConfig = () => JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
// 派生密钥进程级缓存:scryptSync 单次约 70ms+ 且同步阻塞事件循环,
// 逐行解密时重复派生会导致列表接口秒级卡顿(operators/ecs/vas 各 1s+)。
// 仅当 passphrase 或 machine_salt 变化时才重新派生。
let derivedKeyCache = null
let derivedKeyCacheSig = ''
const deriveKey = () => {
const cfg = getConfig()
const salt = Buffer.from(String(cfg.machine_salt || 'default'), 'utf-8')
const saltStr = String(cfg.machine_salt || '')
const passphrase = process.env.CLOUD_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || ''
return crypto.scryptSync(passphrase + String(cfg.machine_salt || ''), salt, 32)
const sig = passphrase + '|' + saltStr
if (derivedKeyCache && sig === derivedKeyCacheSig) return derivedKeyCache
const salt = Buffer.from(saltStr || 'default', 'utf-8')
derivedKeyCache = crypto.scryptSync(passphrase + saltStr, salt, 32)
derivedKeyCacheSig = sig
return derivedKeyCache
}
const enc = plain => {
const key = deriveKey()
@@ -39,6 +49,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS cloud_operators (
login_password TEXT,
mobile TEXT,
contact_person TEXT,
login_url TEXT,
remark TEXT,
is_company_verified INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
@@ -109,6 +120,7 @@ const ensureCols = () => {
if (!hasCol('cloud_operators','login_password_enc')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_enc BLOB`)
if (!hasCol('cloud_operators','login_password_iv')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_iv BLOB`)
if (!hasCol('cloud_operators','login_password_tag')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_password_tag BLOB`)
if (!hasCol('cloud_operators','login_url')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN login_url TEXT`)
if (!hasCol('cloud_operators','remark')) db.exec(`ALTER TABLE cloud_operators ADD COLUMN remark TEXT`)
if (!hasCol('cloud_ecs','rdp_password_enc')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_enc BLOB`)
if (!hasCol('cloud_ecs','rdp_password_iv')) db.exec(`ALTER TABLE cloud_ecs ADD COLUMN rdp_password_iv BLOB`)
@@ -148,8 +160,8 @@ const migrateExisting = () => {
migrateExisting()
const listOperators = db.prepare(`SELECT * FROM cloud_operators ORDER BY id DESC`)
const createOperatorStmt = db.prepare(`INSERT INTO cloud_operators (name, login_account, login_password, mobile, contact_person, remark, is_company_verified, login_password_enc, login_password_iv, login_password_tag) VALUES (@name, @login_account, '', @mobile, @contact_person, @remark, @is_company_verified, @login_password_enc, @login_password_iv, @login_password_tag)`)
const updateOperatorStmt = db.prepare(`UPDATE cloud_operators SET name=@name, login_account=@login_account, login_password='', mobile=@mobile, contact_person=@contact_person, remark=@remark, is_company_verified=@is_company_verified, login_password_enc=@login_password_enc, login_password_iv=@login_password_iv, login_password_tag=@login_password_tag, updated_at=datetime('now') WHERE id=@id`)
const createOperatorStmt = db.prepare(`INSERT INTO cloud_operators (name, login_account, login_password, mobile, contact_person, login_url, remark, is_company_verified, login_password_enc, login_password_iv, login_password_tag) VALUES (@name, @login_account, '', @mobile, @contact_person, @login_url, @remark, @is_company_verified, @login_password_enc, @login_password_iv, @login_password_tag)`)
const updateOperatorStmt = db.prepare(`UPDATE cloud_operators SET name=@name, login_account=@login_account, login_password='', mobile=@mobile, contact_person=@contact_person, login_url=@login_url, remark=@remark, is_company_verified=@is_company_verified, login_password_enc=@login_password_enc, login_password_iv=@login_password_iv, login_password_tag=@login_password_tag, updated_at=datetime('now') WHERE id=@id`)
const deleteOperatorStmt = db.prepare(`DELETE FROM cloud_operators WHERE id=?`)
const listEcs = db.prepare(`SELECT * FROM cloud_ecs ORDER BY id DESC`)
+7 -4
View File
@@ -4784,10 +4784,10 @@ app.get('/api/markets/weekly_report', async (req, res) => {
}
})
// 建行定投评估:按需抓取四大指数 PE-TTM 与近10年分位(点击页面按钮触发,无定时任务)
// 建行定投评估:A股按需评估;海外部分优先读取本地快照,必要时通过美西探针补抓并持久化
app.get('/api/markets/dip_eval', async (req, res) => {
try {
const payload = await marketsDipEval.getDipEvalReport()
const payload = await marketsDipEval.getDipEvalReport({ force: String(req.query.force || '') === '1' })
// 统一 200 + ok 标志:整体失败时 ok=false,前端交互弹窗读取 error 文案
res.json(payload || { ok: false, error: '评估失败' })
} catch (e) {
@@ -5626,7 +5626,7 @@ app.get('/api/cloud/balance_alerts', (req, res) => {
app.get('/api/cloud/balance_alerts/status', (req, res) => {
;(async () => {
try {
const forwarded = await proxyCloudBalanceAutomationStatus('/api/cloud/balance_alerts/status')
const forwarded = await proxyCloudBalanceAutomationStatus('/api/cloud/balance_alerts/status', req)
if (forwarded) return res.status(forwarded.status).json(forwarded.body)
res.json({ ok: true, data: cloudBalanceWatch.getStatus() })
} catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
@@ -5665,13 +5665,15 @@ const proxyCloudBalanceAutomationRequest = async (req, routePath) => {
})
}
}
const proxyCloudBalanceAutomationStatus = async routePath => {
const proxyCloudBalanceAutomationStatus = async (routePath, req) => {
const cfg = cloudBalanceWatch.readConfig()
const currentPort = Number(process.env.PORT || '8976') || 8976
const automationPort = Number(cfg.automation_port || 8977) || 8977
if (currentPort === automationPort) return null
const url = `http://127.0.0.1:${automationPort}${routePath}`
// 必须转发 Cookie,否则 8977 的 /api/cloud 守卫会返回 401
const proxied = await axios.get(url, {
headers: { Cookie: (req && req.headers && req.headers.cookie) || '' },
timeout: 15000,
validateStatus: () => true
})
@@ -7376,6 +7378,7 @@ if (currentPort === marketsAutomationPort) {
securityCalendar.startScheduler()
securityCalendarBridge.startScheduler(securityCalendar)
marketsWeeklyReport.startScheduler()
marketsDipEval.startScheduler()
globalNews.startScheduler()
app.use('/api/yuanzhupai', (req, res, next) => {
File diff suppressed because it is too large Load Diff