feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)

评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
This commit is contained in:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+23 -3
View File
@@ -31,6 +31,7 @@ const AssetsView = {
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select>
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
<button v-if="store.assetTab==='' || store.assetTab==='vps'" @click="syncAllAssets" class="text-sm px-3 py-1.5 rounded-lg border border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20" title="拉取所有已配 API 凭证账号下的云端 VPS 与域名">同步云端资产</button>
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
</div>
@@ -55,10 +56,11 @@ const AssetsView = {
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400 hover:underline mr-3 text-xs" title="远程开机/关机/重启/续费">云操作</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
@@ -81,11 +83,12 @@ const AssetsView = {
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
<button v-if="canControl(a)" @click="openAct(a)" class="text-amber-600 dark:text-amber-400">云操作</button>
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
@@ -156,6 +159,23 @@ const AssetsView = {
await loadAssets();
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
async function syncAllAssets() {
if (!confirm('从云端拉取所有已配 API 凭证账号下的 VPS 与域名?\n\n会按 (平台, 云端实例ID) 去重:已存在则更新状态/到期日,不存在则新建。')) return;
try {
const r = await Api.post('/sync/assets', {});
const lines = [
'同步完成:成功 ' + r.success + ',失败 ' + r.failed,
'新增 ' + r.created + ',更新 ' + r.updated,
];
// 未配凭证 / 平台无适配器属于「跳过」而非失败,但必须如实报出来,
// 否则用户以为同步过了、实际一条都没拉
if (r.skipped_no_config) lines.push('跳过(账号未配 API 凭证):' + r.skipped_no_config);
if (r.skipped_no_adapter) lines.push('跳过(平台无可用适配器):' + r.skipped_no_adapter);
if (r.errors && r.errors.length) lines.push('\n' + r.errors.join('\n'));
alert(lines.join('\n'));
await Promise.all([loadAssets(), loadOverview()]);
} catch (e) { alert('同步失败:' + e.message); }
}
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI, syncAllAssets, canControl: assetCanControl, openAct: openActions };
},
};
+2
View File
@@ -17,6 +17,8 @@ const DashboardView = {
<button type="button" @click="goYearCost" title="点击查看付费资产汇总" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800 hover:border-blue-400 dark:hover:border-blue-600 transition">
<div class="text-xs text-slate-500">本年支出</div>
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
<!-- 合计只统计填了费用的资产,缺口必须说出来:否则这个数看着像准数、实际是下限 -->
<div v-if="store.overview.year_cost_missing" class="text-[11px] mt-1 text-amber-600 dark:text-amber-400">另有 {{ store.overview.year_cost_missing }} 项未填费用,实际更高</div>
</button>
<button type="button" @click="goAbnormal" title="点击查看已停止/已过期的资产" class="text-left bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50 hover:border-amber-400 dark:hover:border-amber-700 transition">
<div class="text-xs text-slate-500">异常状态</div>
+2 -1
View File
@@ -39,6 +39,7 @@ const DomainsView = {
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
<button v-if="canDns(a)" @click="openDnsPanel(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs" title="直接管理云端 DNS 解析记录">DNS</button>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
</td>
</tr>
@@ -164,6 +165,6 @@ const DomainsView = {
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
return 'bg-slate-500/10 text-slate-500';
}
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit, canDns: assetCanDns, openDnsPanel: openDns };
},
};
+2 -2
View File
@@ -83,7 +83,7 @@ const MonitorView = {
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
@@ -107,7 +107,7 @@ const MonitorView = {
</div>
<div class="flex items-center gap-3 mt-2 text-xs">
<span class="text-slate-500">{{ a.expiry_date }}</span>
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
<span :class="a.cost ? 'text-slate-500' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}</span>
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
</div>
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
+30 -2
View File
@@ -25,7 +25,16 @@ const ProvidersView = {
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
</div>
<div class="text-xs text-slate-500 mt-2 space-y-1">
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
<!-- API 对接状态如实展示:能力矩阵来自后端 /providers/adapters,
即「适配器真的实现了哪些方法」,不是拿 sdk_type 有没有值充数 -->
<div v-if="isSupported(p)" class="flex flex-wrap items-center gap-1">
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">✓ API 已对接</span>
<span v-for="c in capsSummary(p)" :key="c" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">{{ c }}</span>
</div>
<div v-else-if="p.sdk_type" class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-red-500/10 text-red-600 dark:text-red-400" :title="'声明的 ' + p.sdk_type + ' 没有对应适配器实现'">
⚠ 已声明 {{ p.sdk_type }},但无对应适配器
</div>
<div v-else class="text-[10px] px-1.5 py-0.5 inline-block rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400">未对接 API(仅台账登记)</div>
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
<div class="flex items-center gap-2 mt-1">
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
@@ -79,10 +88,29 @@ const ProvidersView = {
function serviceList(p) {
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
}
// API 对接状态:以适配器注册表为准,不看 sdk_type 有没有值
function isSupported(p) {
return !!p.sdk_type && (store.adapters.supported_types || []).includes(p.sdk_type);
}
// 能力摘要:把 capabilities 里为 true 的方法归并成用户看得懂的标签
const CAPS_LABELS = [
['list_vps', 'VPS同步'],
['list_domains', '域名同步'],
['get_account', '余额'],
['start_instance', '开关机'],
['reboot_instance', '重启'],
['renew_instance', '续费'],
['list_dns_records', 'DNS查询'],
['create_dns_record', 'DNS写入'],
];
function capsSummary(p) {
const caps = sdkCaps(p.sdk_type);
return CAPS_LABELS.filter(([k]) => caps[k]).map(([, label]) => label);
}
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
return { store, Fmt, countOf, accountsCountOf, serviceList, isSupported, capsSummary, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
},
};
+1 -1
View File
@@ -37,7 +37,7 @@ const SubscriptionsView = {
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="a.cost ? 'text-slate-600 dark:text-slate-300' : 'text-slate-400'">{{ Fmt.costText(a.cost, a.currency) }}<span v-if="a.cost && a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ Fmt.countdownText(a.days_to_expiry, { unit: '' }) }})</span></td>
<td class="px-4 py-2.5 text-right whitespace-nowrap">
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>