fix(review): 修复全量审查发现的域名到期日链路、AI 资产挂靠迁移与 2FA 取码缺陷
92 文件全量代码审查后的高危/中危修复,每条都带回归测试,并在未修复的 HEAD 上
反向确认过「测试确实会失败」。
- H1 域名同步整批失败:NormalizedDomain.expiry_date 契约声明为字符串,Cloudflare
适配器把 Registrar 的 ISO 时间戳截成 "YYYY-MM-DD" 直接赋给 Date 列,写库抛
TypeError;而 _sync_domains 的 commit 在循环外,一个域名就让整批全量回滚
(Cloudflare 域名实际从未同步成功)。现统一契约:base.py 新增 parse_iso_date,
适配器产出 date,_sync_domains 写库前再归一一次以兼容仍返回字符串的适配器
- M9 Vultr / DigitalOcean 的域名到期日此前完全没映射,这两家域名永远进不了续费提醒
- M1 同步失败变 500:能力分支写库失败后未回滚,Session 停在 pending-rollback,
随后更新 last_synced_at 的 commit 抛 PendingRollbackError,真实错误被 500 掩盖
- H2 AI 资产挂靠迁移静默零命中:迁移 SQL 用枚举值 'ai_agent' 匹配 Enum 列,而
SQLAlchemy Enum 存的是成员名 'AI_AGENT',该分支从未生效(6 个 AI 资产的
account_id 全为 NULL)。改用成员名并加日志留痕;账号命名沿用步骤 1 的
「{slug}-默认」约定,避免建出与既有账号语义重复、且没有凭证的空账号
(dry-run 实测:deepseek 资产复用已有且有凭证的 deepseek-默认)
- H3 2FA 动态码不刷新:otpState 全局常驻、收起只清 timer 不删条目,「不存在才拉」
的守卫导致再次展开显示上一次的旧码且倒计时冻结在 0。改为每次展开都重取;
stopOtp 同时清空旧码(避免重展开瞬间闪过已失效的码),并加请求代号,防止
「请求在途时收起」后返回的响应又重启轮询
- M10 账号弹窗关闭不清理:外层用 v-if 关闭并不卸载组件,onUnmounted 不触发,
OTP 轮询定时器与明文密码驻留后台。关闭时显式调 closePwd
- M2 提醒发不出去:Telegram 用 parse_mode=HTML,资产名含 < > & 时整条消息被 400
拒收;纯文本提醒不需要任何标签,去掉 parse_mode,并把拒收正文带回便于排查
- M3 负数天文案:cbb32ea 已修好提醒消息正文,本次补齐仍在直出负数的两处
(check_renewals.py 输出、设置页预览),后端抽出 renewal_service.day_text 统一,
前端走 Fmt.countdownText
验证:pytest 94 passed(新增 tests/test_domain_expiry.py、tests/test_notification.py);
本地起服务对 H2 迁移做真实 dry-run(6 个 AI 资产挂到 4 个账号、无重复账号、幂等、
非 AI 资产零影响,迁移前已备份);浏览器端以 otpState 状态断言验证 H3/M10,
设置页续费预览确认已过期项显示「已过期 N 天」。
This commit is contained in:
+5
-2
@@ -405,9 +405,12 @@ const AccountsViewModal = {
|
||||
await Promise.all([loadAccounts(), loadAssets()]);
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
// 每次打开重置展开状态
|
||||
// 每次打开重置展开状态;关闭时连带收掉密码弹窗
|
||||
// 外层弹窗用 v-if 控制,但本组件实例并不卸载,onUnmounted 不会触发:
|
||||
// 不主动清理就会留下 OTP 轮询定时器与驻留的明文密码,重开弹窗还会复现旧内容
|
||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||
if (show) { for (const k of Object.keys(expanded)) delete expanded[k]; }
|
||||
else closePwd();
|
||||
});
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
||||
async function viewPwd(a) {
|
||||
|
||||
+14
-2
@@ -413,15 +413,20 @@ async function copyAccountPassword(a) {
|
||||
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
|
||||
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
|
||||
const otpState = Vue.reactive({});
|
||||
// 取码请求代号:stopOtp 会推进代号,让在途请求返回后不再写状态/重启倒计时
|
||||
// (否则「请求在途时收起」会让已收起的条目在后台持续轮询动态码)
|
||||
const otpGen = {};
|
||||
async function fetchOtp(id) {
|
||||
// 先经 Proxy 创建占位对象,再取回响应式代理。
|
||||
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
|
||||
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
|
||||
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
|
||||
const s = otpState[id];
|
||||
const gen = (otpGen[id] = (otpGen[id] || 0) + 1);
|
||||
s.loading = true;
|
||||
try {
|
||||
const r = await Api.get('/credentials/' + id + '/otp');
|
||||
if (otpGen[id] !== gen) return; // 期间已被 stopOtp 取消
|
||||
s.code = r.code;
|
||||
s.expiresIn = r.expires_in;
|
||||
if (s.timer) clearInterval(s.timer);
|
||||
@@ -431,11 +436,18 @@ async function fetchOtp(id) {
|
||||
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
|
||||
}, 1000);
|
||||
} catch (e) { store.error = e.message; }
|
||||
finally { s.loading = false; }
|
||||
finally { if (otpGen[id] === gen) s.loading = false; }
|
||||
}
|
||||
function stopOtp(id) {
|
||||
otpGen[id] = (otpGen[id] || 0) + 1;
|
||||
const s = otpState[id];
|
||||
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
|
||||
if (!s) return;
|
||||
if (s.timer) { clearInterval(s.timer); s.timer = null; }
|
||||
// 停表同时清掉旧码:otpState 是全局且条目常驻,留着会让下次展开
|
||||
// 在重新取码前闪一下已过期的码(用户有可能把旧码复制走)
|
||||
s.code = '';
|
||||
s.expiresIn = 0;
|
||||
s.loading = false;
|
||||
}
|
||||
function stopAllOtp() {
|
||||
for (const id of Object.keys(otpState)) stopOtp(Number(id));
|
||||
|
||||
@@ -63,8 +63,11 @@ const SettingsView = {
|
||||
notifyResult.value = '检查中…';
|
||||
try {
|
||||
const r = await Api.post('/notify/renewals?send=true', {});
|
||||
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
||||
// days 可为负(已过期),文案统一走 Fmt.countdownText,避免直出「-3 天后」
|
||||
const overdue = r.expiring.filter(i => i.days < 0).length;
|
||||
let text = (overdue ? '已过期 ' + overdue + ' 项 + ' : '') +
|
||||
'即将到期 ' + (r.expiring_count - overdue) + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + Fmt.countdownText(i.days)).join('\n');
|
||||
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||||
notifyResult.value = text;
|
||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||
|
||||
@@ -130,7 +130,9 @@ const VaultView = {
|
||||
const open = forceOpen ? true : !expanded[c.id];
|
||||
expanded[c.id] = open;
|
||||
if (!c.has_otp) return;
|
||||
if (open && !otpState[c.id]) fetchOtp(c.id);
|
||||
// 每次展开都重取:otpState 是全局的、收起只清 timer 不删条目,
|
||||
// 若按「不存在才拉」判断,再次展开会显示上一次的旧码且倒计时冻在 0
|
||||
if (open) fetchOtp(c.id);
|
||||
if (!open) stopOtp(c.id);
|
||||
}
|
||||
async function reveal(c) {
|
||||
|
||||
Reference in New Issue
Block a user