${esc(a.name)}${comBadge}`
+ html: `
${esc(a.name)}${comBadge}`
+ ``
+ `
${execBadge}
${bsign}${pills}
`,
};
diff --git a/src/index.js b/src/index.js
index 8a8c6b9..2357956 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1688,6 +1688,21 @@ export default {
}
if (changed) await setSetting(env.DB, 'sched_last_map', JSON.stringify(lastMap)).catch(() => {});
+ // ---- 后台凭据续期(独立于签到)----
+ // 每小时最多跑一轮:把「站点声明了 renew() 的账号」里快过期的凭据提前换新。
+ // 故意放在签到主循环之后、且不计入上面的 ranCount/日报 —— 这是保养,
+ // 不是签到;续期自己的成败写在账号 meta 里(renew_ok_at / renew_fail_*),
+ // 前端「凭据到期时间」悬浮提示会展示,失败且快过期时会推送提醒。
+ try {
+ const { renewCredentials } = await import('./lib/renew.js');
+ const renewRes = await renewCredentials(env, env.DB, await loadCustomSites(env).catch(() => []));
+ if (renewRes && renewRes.ran && renewRes.details && renewRes.details.length) {
+ console.log('[cron] 凭据续期:' + renewRes.details.join(';'));
+ }
+ } catch (e) {
+ console.error('[cron] 凭据续期失败', e);
+ }
+
// 心跳:记下「这次自动检查是什么时候跑的、跑了几个」。
// 没到点的大多数分钟(ranCount=0)不必写库,所以最多半小时写一次 ——
// 这样面板既能显示「自动执行还活着」,也几乎不增加 D1 写入。
diff --git a/src/lib/renew.js b/src/lib/renew.js
new file mode 100644
index 0000000..ddf01be
--- /dev/null
+++ b/src/lib/renew.js
@@ -0,0 +1,208 @@
+// 后台凭据续期:独立于签到流程,每小时扫一遍账号,做两件事:
+// ① 有 renew() 的站点(如 Akile):快过期的凭据提前换新并回写;
+// ② 纯 Cookie 的站点(没有刷新接口、手里也没密码,续不了):凭据里写着到期时间
+// 的,剩不到 24 小时就提前预警,提醒用户去重新抓(每天每个账号一次)。
+//
+// 为什么需要(2026-10-01):Akile 的 token 只有 ~24h 寿命,原来续期只发生在
+// 签到那一刻;那一刻网络一抖(CF 到 Akile 本来就不稳定)或扩展离线,续期就被
+// 静默吞掉——旧 token 当时还能用,签到照样成功,用户毫无察觉;第二天 token
+// 一死,就只能手动重新登录获取。这个独立任务治的就是这个:提前续、失败了
+// 提前说,不等死了才让用户手动。
+//
+// 站点侧约定见 akile.js 的 renew() 注释;调度器只认那个形状,别的新站点
+// 照着实现就能接入。
+
+import { getSite } from '../sites/index.js';
+import { decryptJSON } from '../crypto.js';
+import { getSetting, setSetting } from '../db.js';
+import { withAccountLock } from '../runner.js';
+import { credentialExpiry } from './cookie-info.js';
+
+const RENEW_INTERVAL_MS = 60 * 60 * 1000; // 每小时最多跑一轮(12 小时窗口内约有 12 次机会)
+const ALERT_WITHIN_MS = 6 * 3600 * 1000; // 剩不到 6 小时就死、且续期失败 → 推送提醒(每天一次)
+const EXP_ALERT_WITHIN_MS = 24 * 3600 * 1000; // 纯 Cookie 站点:凭据剩不到 24 小时 → 到期预警(每天每个账号一次)
+
+const REASON_TEXT = {
+ network: '网络没通',
+ rejected: '站点拒绝续期(旧凭据已失效)',
+ invalid: '存的凭据格式不对',
+ error: '续期过程出错',
+};
+
+function fmtTime(ms) {
+ try {
+ return new Date(ms).toLocaleString('zh-CN',
+ { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+ } catch { return ''; }
+}
+
+// 一次续期尝试:扩展在线先走用户本地网络(中继),再走直连兜底。
+// 只有「网络失败」值得换路线再试;not-due/已换新/被拒绝/代码异常都不用再试。
+async function renewWithRoutes(env, db, account, site, creds) {
+ const routes = [];
+ try {
+ const { isRelayAvailable } = await import('./relay.js');
+ if (await isRelayAvailable(db)) routes.push('relay');
+ } catch { /* 扩展状态查不到就当离线,直连兜底 */ }
+ routes.push('server');
+ let last = null;
+ for (const route of routes) {
+ let restore = null;
+ if (route === 'relay') {
+ const { relayFetch } = await import('./relay.js');
+ const prev = globalThis.fetch;
+ globalThis.fetch = (url, init) => relayFetch(db, url, init);
+ restore = () => { globalThis.fetch = prev; };
+ }
+ try {
+ const r = await site.renew(creds, { env, db, account });
+ last = { route, ...r };
+ if (r.renewed || r.reason !== 'network') break;
+ } catch (e) {
+ last = { route, renewed: false, reason: 'error', exp: 0, detail: String((e && e.message) || e).slice(0, 200) };
+ break;
+ } finally {
+ if (restore) restore();
+ }
+ }
+ return last;
+}
+
+function metaOf(acc) {
+ try { return JSON.parse(acc.meta || '{}'); } catch { return {}; }
+}
+
+// 纯 Cookie 站点的「到期预警」:Cookie 没有刷新接口、面板手里也没有密码,
+// 技术上续不了 —— 但能提前发现快死了,提前一天告诉用户去重新抓,
+// 而不是等第二天签到失败才看到「登录已失效」。
+// 只认凭据里自己写着的到期时间(WordPress 会话 / JWT),没有的不猜。
+async function checkExpiryAlert(env, db, full, site, creds) {
+ const now = Date.now();
+ let nearest = 0;
+ let anyExpired = false;
+ try {
+ const { sessions, tokens } = credentialExpiry(creds);
+ const all = [...(sessions || []), ...(tokens || [])].filter((x) => x && x.exp > 0);
+ if (!all.length) return 'no-info';
+ const live = all.filter((x) => !x.expired).map((x) => x.exp).sort((a, b) => a - b);
+ nearest = live.length ? live[0] : 0;
+ anyExpired = all.some((x) => x.expired);
+ } catch { return 'no-info'; }
+ const dying = nearest > 0 && nearest - now < EXP_ALERT_WITHIN_MS;
+ if (!dying && !anyExpired) return 'fine';
+ // 同一天同一个账号只提醒一次
+ const m = metaOf(full);
+ try {
+ const tz = (await getSetting(db, 'schedule_tz').catch(() => '')) || 'Asia/Shanghai';
+ const { dayInTz } = await import('../schedule.js').catch(() => ({}));
+ const day = typeof dayInTz === 'function' ? dayInTz(new Date(), tz) : new Date().toDateString();
+ if (m.exp_alert_day === day) return 'already';
+ const { sendNotify } = await import('../notify.js');
+ const leftH = nearest > now ? Math.max(1, Math.round((nearest - now) / 3600000)) : 0;
+ const when = nearest > now
+ ? `将在 ${fmtTime(nearest)} 过期(还剩约 ${leftH} 小时)`
+ : '已经过期了';
+ await sendNotify(env, db,
+ 'Cookie 即将过期',
+ `${full.name || site.name} 的登录 Cookie ${when}。Cookie 没有自动续期,` +
+ `请在浏览器重新登录该网站(确认页面上能看到自己的用户名),再用扩展「发送到面板」更新一次。`);
+ m.exp_alert_day = day;
+ await db.prepare('UPDATE accounts SET meta = ?, updated_at = ? WHERE id = ?')
+ .bind(JSON.stringify(m), now, full.id).run().catch(() => {});
+ return 'notified';
+ } catch (e) {
+ console.error('[renew] 到期预警失败', e);
+ return 'error';
+ }
+}
+
+export async function renewCredentials(env, db, customSites = []) {
+ let lastRun = 0;
+ try { lastRun = Number(await getSetting(db, 'renew_last_at')) || 0; } catch { /* 忽略 */ }
+ if (Date.now() - lastRun < RENEW_INTERVAL_MS) return { ran: false, reason: 'too-soon' };
+ await setSetting(db, 'renew_last_at', String(Date.now())).catch(() => {});
+
+ const { results } = await db.prepare(
+ 'SELECT id, site, name, meta, creds FROM accounts WHERE enabled = 1'
+ ).all().catch(() => ({ results: [] }));
+
+ const summary = { ran: true, checked: 0, renewed: 0, failed: 0, details: [] };
+ for (const acc of results || []) {
+ let site = null;
+ try { site = getSite(acc.site, customSites); } catch { /* 忽略 */ }
+ if (!site) continue;
+ // 和签到共用账号锁:同一账号的签到和续期不能同时跑(都会读写它的凭据)
+ try {
+ await withAccountLock(async () => {
+ const full = await db.prepare('SELECT * FROM accounts WHERE id = ?').bind(acc.id).first();
+ if (!full || !full.enabled) return;
+ let creds = {};
+ try { creds = await decryptJSON(env, db, full.creds); } catch { return; }
+ summary.checked++;
+ // 没有 renew() 的站点(纯 Cookie、手里没密码):续不了,但能做「到期预警」
+ if (typeof site.renew !== 'function') {
+ const st = await checkExpiryAlert(env, db, full, site, creds);
+ if (st === 'notified') {
+ summary.details.push(`⚠️ ${full.name || site.name}:Cookie 快过期,已提醒重新获取`);
+ }
+ return;
+ }
+ const r = await renewWithRoutes(env, db, full, site, creds);
+ if (!r) return;
+ const m = metaOf(full);
+ const now = Date.now();
+ let touched = false;
+ if (r.renewed) {
+ m.renew_ok_at = now;
+ m.renew_ok_exp = r.exp || 0;
+ delete m.renew_fail_at;
+ delete m.renew_fail_reason;
+ delete m.renew_fail_detail;
+ touched = true;
+ summary.renewed++;
+ summary.details.push(`✅ ${full.name || site.name}:token 已自动续期` + (r.exp ? `(新有效期至 ${fmtTime(r.exp * 1000)})` : ''));
+ } else if (r.reason === 'not-due' || r.reason === 'invalid') {
+ // 还没到时间 / 凭据格式不对(后者等签到流程去跟用户说清楚,这里不打扰)
+ } else {
+ m.renew_fail_at = now;
+ m.renew_fail_reason = r.reason;
+ m.renew_fail_detail = String(r.detail || '').slice(0, 200);
+ touched = true;
+ summary.failed++;
+ summary.details.push(`❌ ${full.name || site.name}:自动续期失败(${REASON_TEXT[r.reason] || r.reason})${r.detail ? ':' + String(r.detail).slice(0, 120) : ''}`);
+ }
+ if (touched) {
+ await db.prepare('UPDATE accounts SET meta = ?, updated_at = ? WHERE id = ?')
+ .bind(JSON.stringify(m), now, full.id).run().catch(() => {});
+ }
+ // 续期失败、且凭据剩不到 6 小时就死(或已经死了)→ 推送提醒(每天一次,免得每小时刷屏)
+ if (!r.renewed && (r.reason === 'network' || r.reason === 'rejected' || r.reason === 'error')
+ && r.exp && r.exp * 1000 - now < ALERT_WITHIN_MS) {
+ try {
+ const tz = (await getSetting(db, 'schedule_tz').catch(() => '')) || 'Asia/Shanghai';
+ const { dayInTz } = await import('../schedule.js').catch(() => ({}));
+ const day = typeof dayInTz === 'function' ? dayInTz(new Date(), tz) : new Date().toDateString();
+ if ((await getSetting(db, 'renew_alert_day').catch(() => '')) !== `renew:${day}`) {
+ const { sendNotify } = await import('../notify.js');
+ const expStr = fmtTime(r.exp * 1000);
+ const when = r.exp * 1000 <= now
+ ? `已经过期(${expStr}),且自动续期失败了`
+ : `将在 ${expStr} 过期(还剩不到 6 小时),但自动续期失败了`;
+ await sendNotify(env, db,
+ '凭据自动续期失败',
+ `${full.name || site.name} 的登录凭据${when}:` +
+ `${REASON_TEXT[r.reason] || r.reason}${r.detail ? '(' + String(r.detail).slice(0, 120) + ')' : ''}。` +
+ `面板会继续每小时重试;若一直失败,请手动重新获取凭据。`);
+ await setSetting(db, 'renew_alert_day', `renew:${day}`).catch(() => {});
+ }
+ } catch (e) {
+ console.error('[renew] 推送提醒失败', e);
+ }
+ }
+ });
+ } catch (e) {
+ console.error('[renew] account', acc.id, e);
+ }
+ }
+ return summary;
+}
diff --git a/src/runner.js b/src/runner.js
index 28cbbdb..d348533 100644
--- a/src/runner.js
+++ b/src/runner.js
@@ -44,6 +44,9 @@ function withAccountLock(fn) {
export function accountLockIdle() {
return ACCOUNT_LOCK;
}
+// 凭据续期(src/lib/renew.js)与签到共用同一把账号锁:同一账号的签到和续期
+// 不能同时跑(都会读写它的凭据)。
+export { withAccountLock };
// 仅供测试:Set-Cookie 静默回写(生产代码走 runAccount 内部调用)
export { applyCookieRefresh };
diff --git a/src/sites/akile.js b/src/sites/akile.js
index cf7f43d..ee6379d 100644
--- a/src/sites/akile.js
+++ b/src/sites/akile.js
@@ -13,6 +13,7 @@ import { encryptJSON } from '../crypto.js';
const UA = 'Mozilla/5.0 (Linux; Android 13; KB2000 Build/TKQ1.221114.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36';
const API = 'https://api.akile.ai/api';
const REFRESH_AHEAD_SEC = 12 * 3600; // 与网页一致:过期前 12 小时刷新
+const JWT_RE = /^[\w-]+\.[\w-]+\.[\w-]+$/;
function okCode(code) {
return code === 0 || code === 200 || code === '0' || code === '200';
@@ -52,16 +53,35 @@ async function apiGet(path, token) {
return { httpStatus: res.status, body };
}
-// 用旧 token 换新 token;失败返回 null
-async function refreshToken(token) {
+// 用旧 token 换新 token(结构化结果——调用方必须分清「被站点拒绝」和「网络没通」:
+// 前者再试也没用,后者换条路线/过会儿重试可能有用。原来一律返回 null,
+// 网络抖一下就会被当成「token 已死」,用户看到的就是莫名其妙的「请重新获取)。
+// { token } —— 换到了新 token
+// { rejected, detail } —— 站点明确拒绝(401 / status_code 非成功码):旧 token 已死
+// { network, detail } —— 请求没发出去或没回来(超时/断网/被拦):值得重试
+async function tryRefresh(token) {
+ let res;
try {
- const { httpStatus, body } = await apiGet('/v1/user/refreshToken', token);
- if (httpStatus === 401 || !okCode(body.status_code)) return null;
- const nt = body && body.data && body.data.token;
- return nt ? String(nt) : null;
- } catch {
- return null;
+ res = await apiGet('/v1/user/refreshToken', token);
+ } catch (e) {
+ return { network: true, detail: String((e && e.message) || e || '请求失败').slice(0, 160) };
}
+ const { httpStatus, body } = res;
+ if (httpStatus === 401 || !okCode(body.status_code)) {
+ const msg = String((body && body.status_msg) || '').slice(0, 120);
+ return { rejected: true, detail: `HTTP ${httpStatus} · ${msg || '无返回文案'}` };
+ }
+ const nt = body && body.data && body.data.token;
+ if (!nt) {
+ return { rejected: true, detail: `HTTP ${httpStatus} · 接口成功了但没给新 token(返回里没有 data.token)` };
+ }
+ return { token: String(nt) };
+}
+
+// 签到主流程用(行为不变:失败一律 null,由 run() 按原来的文案报错)
+async function refreshToken(token) {
+ const r = await tryRefresh(token);
+ return r.token || null;
}
// 续期成功后回写 D1,下次直接用新 token(失败不影响本次签到)
@@ -96,7 +116,7 @@ export const akile = {
placeholder: '浏览器登录 akile.ai 后,从 localStorage 复制 akile-token(一段 eyJ... 开头的 JWT)粘贴到这里',
},
],
- tips: '一句话记住:这个框要的是 akile-token,**不是 Cookie**。它们在两个完全不同的地方:Cookie 在 F12 → Application → Cookies,token 在 F12 → Application → Local Storage → akile-token(值以 eyJ 开头,是一段 JWT)。粘贴 Cookie 会被服务器拒绝,面板也只能回「登录已过期」,看着就像「我明明更新了」。最省事的取法:在浏览器登录 akile.ai → 点下方「复制取 token 小书签」→ 在 akile.ai 页面点该书签,token 会自动复制 → 回到面板粘贴保存。另外一个细节:Akile 的 token 大约只活一天(实测 exp 与签发时间相差 12~24 小时),到期后必须重新登录获取,面板会在即将过期时尝试自动续期。',
+ tips: '一句话记住:这个框要的是 akile-token,**不是 Cookie**。它们在两个完全不同的地方:Cookie 在 F12 → Application → Cookies,token 在 F12 → Application → Local Storage → akile-token(值以 eyJ 开头,是一段 JWT)。粘贴 Cookie 会被服务器拒绝,面板也只能回「登录已过期」,看着就像「我明明更新了」。最省事的取法:在浏览器登录 akile.ai → 点下方「复制取 token 小书签」→ 在 akile.ai 页面点该书签,token 会自动复制 → 回到面板粘贴保存。另外一个细节:Akile 的 token 大约只活一天(实测 exp 与签发时间相差 12~24 小时)。面板会在后台每小时检查一次,到期前 12 小时自动续期并回写,你一般不用管;万一续期失败(比如网络不通),面板会提前提醒你手动更新,而不是等 token 死了才报错。',
async run(creds, ctx = {}) {
let token = String(creds.token || '').trim();
@@ -108,7 +128,7 @@ export const akile = {
// 「登录已过期,请重新从浏览器复制 akile-token」——因为他更新的是 **Cookie**,
// 而 Akile 要的是 localStorage 里的 `akile-token`(一段 JWT),两者不是一个东西。
// 所以先分辨值形态,把话说清楚,别拿一个 Cookie 去当 Authorization 白打一次请求。
- const looksJwt = /^[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
+ const looksJwt = JWT_RE.test(token);
if (!looksJwt) {
const looksCookie = token.includes('=') || token.includes(';');
throw new Error(looksCookie
@@ -169,6 +189,29 @@ export const akile = {
throw new Error('签到失败:' + (msg || `status_code=${chk.body.status_code}`));
},
+ // 后台自动续期(独立于签到流程):调度器每小时调用一次,提前把快过期的 token 换新。
+ // 约定(别的站点以后也可以照这个实现,调度器只认这个形状):
+ // { renewed: true, exp } —— 已换新并回写 D1(exp 为新 token 的到期秒数)
+ // { renewed: false, reason: 'not-due', exp } —— 还没到续期时间,不用管
+ // { renewed: false, reason: 'invalid', exp: 0 } —— 存的值不是 JWT(等签到流程去跟用户说清楚)
+ // { renewed: false, reason: 'network', exp, detail } —— 网络没通:调度器会换路线再试/下小时再试
+ // { renewed: false, reason: 'rejected', exp, detail } —— 站点拒绝:旧 token 已死,只能手动重新获取
+ // 注意:这里用的 globalThis.fetch 由调用方按路线准备好(中继=用户本地网络 / 直连=机房网络)。
+ async renew(creds, ctx = {}) {
+ const token = String(creds.token || '').trim();
+ if (!JWT_RE.test(token)) return { renewed: false, reason: 'invalid', exp: 0 };
+ const exp = jwtExp(token);
+ if (!exp) return { renewed: false, reason: 'not-due', exp: 0 };
+ if (exp - Date.now() / 1000 > REFRESH_AHEAD_SEC) return { renewed: false, reason: 'not-due', exp };
+ const r = await tryRefresh(token);
+ if (r.token) {
+ await saveToken(ctx, r.token);
+ return { renewed: true, exp: jwtExp(r.token) || 0 };
+ }
+ if (r.rejected) return { renewed: false, reason: 'rejected', exp, detail: r.detail };
+ return { renewed: false, reason: 'network', exp, detail: r.detail };
+ },
+
// 浏览器端签到脚本:在用户浏览器中运行,使用用户本地网络(绕过 CF IP 限制)
execution: 'browser', // 改为默认浏览器执行(CF 到 akile 网络不稳定)
domain: 'api.akile.ai', // 浏览器执行时的目标域名
diff --git a/src/version.js b/src/version.js
index 08af77c..57acf08 100644
--- a/src/version.js
+++ b/src/version.js
@@ -7,4 +7,4 @@
// · 测试会断言 package.json 的 version 和它一致(改了这里忘了改那边会直接报红)
// 扩展的版本在 public/ext-src/manifest.json 里(它有自己的发布节奏:改过扩展代码,
// 用户就得重新下载 + 在 chrome://extensions 点一次「重新加载」,所以必须单独能看出来)。
-export const PANEL_VERSION = '2.19.1';
+export const PANEL_VERSION = '2.20.0';
diff --git a/test/renew.test.mjs b/test/renew.test.mjs
new file mode 100644
index 0000000..c31c4c5
--- /dev/null
+++ b/test/renew.test.mjs
@@ -0,0 +1,272 @@
+// 后台自动续期测试:node test/renew.test.mjs(纯 mock,不依赖网络)
+// 覆盖:akile.renew() 的五种返回形态 + renewCredentials() 的整轮调度
+import assert from 'node:assert/strict';
+import { akile, jwtExp } from '../src/sites/akile.js';
+import { encryptJSON } from '../src/crypto.js';
+import { renewCredentials } from '../src/lib/renew.js';
+
+let n = 0;
+const t = async (name, fn) => { await fn(); n++; console.log('ok -', name); };
+
+function mockFetch(routes) {
+ const calls = [];
+ globalThis.fetch = async (url, init = {}) => {
+ calls.push({ url: String(url), init });
+ globalThis.__calls = calls;
+ for (const [match, resp] of routes) {
+ if (String(url).includes(match)) {
+ const body = typeof resp === 'function' ? resp(calls.length) : resp;
+ const status = body && body.__http ? body.__http : 200;
+ return { status, json: async () => body, text: async () => JSON.stringify(body) };
+ }
+ }
+ throw new Error('unexpected url: ' + url);
+ };
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+const fakeJwt = (expDeltaSec) => `${b64({ alg: 'HS256' })}.${b64({ exp: Math.floor(Date.now() / 1000) + expDeltaSec })}.sig`;
+const LONG_TOKEN = fakeJwt(20 * 3600);
+const SOON_TOKEN = fakeJwt(2 * 3600);
+const NEW_TOKEN = fakeJwt(24 * 3600);
+
+const checkinOk = { status_code: 0, status_msg: '签到成功', data: { amount: 8 } };
+const refreshOk = (tok) => ({ status_code: 0, status_msg: 'ok', data: { token: tok } });
+
+function fakeCtx() {
+ const writes = [];
+ const FIXED_KEY = Buffer.alloc(32, 7).toString('base64');
+ const db = {
+ prepare: (sql) => ({
+ bind: (...args) => ({ run: async () => { writes.push({ sql, args }); return {}; } }),
+ first: async () => ({ value: FIXED_KEY }),
+ }),
+ };
+ return { ctx: { env: {}, db, account: { id: 'acc1' } }, writes };
+}
+
+await t('renew:长期 token 还没到时间,不发请求', async () => {
+ mockFetch([['any', checkinOk]]);
+ globalThis.__calls = [];
+ const r = await akile.renew({ token: LONG_TOKEN }, fakeCtx().ctx);
+ assert.equal(r.renewed, false);
+ assert.equal(r.reason, 'not-due');
+ assert.equal(globalThis.__calls.length, 0);
+});
+
+await t('renew:临期 token 刷新并回写,新 exp 向后', async () => {
+ mockFetch([['/v1/user/refreshToken', refreshOk(NEW_TOKEN)]]);
+ const { ctx, writes } = fakeCtx();
+ const oldExp = jwtExp(SOON_TOKEN);
+ const r = await akile.renew({ token: SOON_TOKEN }, ctx);
+ assert.equal(r.renewed, true);
+ assert.ok(r.exp > oldExp, '新 token 的 exp 必须向后');
+ assert.equal(writes.length, 1);
+ assert.match(writes[0].sql, /UPDATE accounts/);
+});
+
+await t('renew:站点 401 拒绝 → rejected(不是 network)', async () => {
+ mockFetch([['/v1/user/refreshToken', { status_code: 401, status_msg: '无效token' }]]);
+ const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx);
+ assert.equal(r.renewed, false);
+ assert.equal(r.reason, 'rejected');
+ assert.match(r.detail, /无效token/);
+});
+
+await t('renew:请求抛错 → network(值得换路线重试)', async () => {
+ globalThis.fetch = async () => { throw new Error('fetch failed'); };
+ const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx);
+ assert.equal(r.renewed, false);
+ assert.equal(r.reason, 'network');
+ assert.match(r.detail, /fetch failed/);
+});
+
+await t('renew:存的不是 JWT → invalid,不发请求', async () => {
+ mockFetch([['any', checkinOk]]);
+ globalThis.__calls = [];
+ const r = await akile.renew({ token: 'PHPSESSID=abc; foo=1' }, fakeCtx().ctx);
+ assert.equal(r.renewed, false);
+ assert.equal(r.reason, 'invalid');
+ assert.equal(globalThis.__calls.length, 0);
+});
+
+await t('renew:接口成功但没给 data.token → rejected(结构变了要能看出来)', async () => {
+ mockFetch([['/v1/user/refreshToken', { status_code: 0, status_msg: 'ok', data: {} }]]);
+ const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx);
+ assert.equal(r.renewed, false);
+ assert.equal(r.reason, 'rejected');
+ assert.match(r.detail, /data\.token/);
+});
+
+// ---------- renewCredentials 整轮 ----------
+const ENC_KEY = Buffer.alloc(32, 9).toString('base64');
+const ENV = { ENCRYPT_KEY: ENC_KEY };
+
+function fakeDb(accounts) {
+ const settings = {};
+ const updates = [];
+ // 有状态:meta / creds 的回写要能被后面的 first() 读到(否则"同一天不重复提醒"测不起来)
+ const byId = new Map(accounts.map((a) => [String(a.id), { ...a }]));
+ const list = () => [...byId.values()].filter((a) => a.enabled);
+ return {
+ settings, updates,
+ prepare: (sql) => ({
+ bind: (...args) => ({
+ run: async () => {
+ if (sql.startsWith('UPDATE accounts')) {
+ updates.push({ sql, args });
+ const row = byId.get(String(args[args.length - 1]));
+ if (row && /SET meta/.test(sql)) row.meta = args[0];
+ if (row && /SET creds/.test(sql)) row.creds = args[0];
+ }
+ return {};
+ },
+ first: async () => {
+ if (sql.startsWith('SELECT value FROM settings')) {
+ const k = args[0];
+ return settings[k] !== undefined ? { value: settings[k] } : null;
+ }
+ if (sql.startsWith('SELECT * FROM accounts')) {
+ return byId.get(String(args[0])) || null;
+ }
+ return null;
+ },
+ all: async () => {
+ if (sql.includes('FROM accounts WHERE enabled')) return { results: list() };
+ return { results: [] };
+ },
+ }),
+ first: async () => null,
+ all: async () => {
+ if (sql.includes('FROM accounts WHERE enabled')) return { results: list() };
+ return { results: [] };
+ },
+ run: async () => {
+ return {};
+ },
+ }),
+ };
+}
+
+// fakeDb 的 prepare().bind().run() 要能处理 setSetting 的 INSERT…ON CONFLICT:
+// 上面 bind().run() 没记 settings,这里单独包一层
+function settingsAwareDb(accounts) {
+ const db = fakeDb(accounts);
+ const origPrepare = db.prepare;
+ db.prepare = (sql) => {
+ const stmt = origPrepare(sql);
+ if (sql.startsWith('INSERT INTO settings')) {
+ const origBind = stmt.bind;
+ stmt.bind = (...args) => {
+ const r = origBind(...args);
+ const origRun = r.run;
+ r.run = async () => { db.settings[args[0]] = args[1]; return origRun(); };
+ return r;
+ };
+ }
+ return stmt;
+ };
+ return db;
+}
+
+async function makeAccounts() {
+ const db0 = settingsAwareDb([]);
+ const soonCreds = await encryptJSON(ENV, db0, { token: SOON_TOKEN });
+ const longCreds = await encryptJSON(ENV, db0, { token: LONG_TOKEN });
+ const plainCookie = await encryptJSON(ENV, db0, { cookie: 'sessionid=abc123' });
+ return [
+ { id: 1, site: 'akile', name: 'Akile', enabled: 1, meta: '{}', creds: soonCreds },
+ { id: 2, site: 'akile', name: 'Akile长期', enabled: 1, meta: '{}', creds: longCreds },
+ { id: 3, site: 'v2ex', name: 'V2EX', enabled: 1, meta: '{}', creds: plainCookie }, // 没有 renew → 只做到期预警;这份 Cookie 里没写到期时间,不该被碰
+ ];
+}
+
+await t('renewCredentials:临期账号被续期并写 meta;长期/无 renew 的不动', async () => {
+ mockFetch([['/v1/user/refreshToken', refreshOk(NEW_TOKEN)]]);
+ const db = settingsAwareDb(await makeAccounts());
+ const res = await renewCredentials(ENV, db, []);
+ assert.equal(res.ran, true);
+ assert.equal(res.checked, 3, '三个账号都会被扫(akile 走续期,v2ex 走到期预警)');
+ assert.equal(res.renewed, 1);
+ const upd = db.updates.find((u) => /SET meta/.test(u.sql) && String(u.args[2]) === '1');
+ assert.ok(upd, '账号 1 写了 meta');
+ const m = JSON.parse(upd.args[0]);
+ assert.ok(m.renew_ok_at > 0);
+ // 凭据本身也被换了(saveToken 走的 UPDATE accounts SET creds=…)
+ const credUpd = db.updates.find((u) => /creds/.test(u.sql) && String(u.args[2]) === '1');
+ assert.ok(credUpd, '账号 1 的 creds 被回写');
+ assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql) && String(u.args[u.args.length - 1]) === '3'), 'v2ex 的 meta 不该被碰(它的 Cookie 里没写到期时间)');
+});
+
+await t('renewCredentials:一小时内不重复跑', async () => {
+ mockFetch([['any', refreshOk(NEW_TOKEN)]]);
+ globalThis.__calls = [];
+ const db = settingsAwareDb(await makeAccounts());
+ db.settings['renew_last_at'] = String(Date.now());
+ const res = await renewCredentials(ENV, db, []);
+ assert.equal(res.ran, false);
+ assert.equal(globalThis.__calls.length, 0);
+});
+
+await t('renewCredentials:网络失败记 fail 原因,不抛错', async () => {
+ globalThis.fetch = async () => { throw new Error('socket hang up'); };
+ const db = settingsAwareDb(await makeAccounts());
+ const res = await renewCredentials(ENV, db, []);
+ assert.equal(res.ran, true);
+ assert.equal(res.failed, 1);
+ const upd = db.updates.find((u) => /SET meta/.test(u.sql) && String(u.args[u.args.length - 1]) === '1');
+ const m = JSON.parse(upd.args[0]);
+ assert.equal(m.renew_fail_reason, 'network');
+ assert.match(m.renew_fail_detail, /socket hang up/);
+});
+
+console.log(`\n${n} passed`);
+
+// ---------- 纯 Cookie 站点的到期预警 ----------
+const wpCookie = (expDeltaSec) =>
+ `wordpress_logged_in_abc=testuser|${Math.floor(Date.now() / 1000) + expDeltaSec}|sessiontoken`;
+
+async function cookieAccounts(cookieStr, meta = '{}') {
+ const db0 = settingsAwareDb([]);
+ const creds = await encryptJSON(ENV, db0, { cookie: cookieStr });
+ return [{ id: 7, site: 'hutue', name: '糊涂鳄', enabled: 1, meta, creds }];
+}
+
+await t('到期预警:Cookie 剩 2 小时 → 提醒一次并记 exp_alert_day', async () => {
+ mockFetch([]);
+ const db = settingsAwareDb(await cookieAccounts(wpCookie(2 * 3600)));
+ const res = await renewCredentials(ENV, db, []);
+ assert.equal(res.ran, true);
+ assert.ok(res.details.some((d) => d.includes('Cookie 快过期')), '详情里要有预警:' + res.details.join('|'));
+ const upd = db.updates.find((u) => /SET meta/.test(u.sql));
+ assert.ok(upd, '要写 exp_alert_day');
+ assert.ok(JSON.parse(upd.args[0]).exp_alert_day, 'exp_alert_day 已记');
+});
+
+await t('到期预警:同一天不重复提醒', async () => {
+ mockFetch([]);
+ const db = settingsAwareDb(await cookieAccounts(wpCookie(2 * 3600)));
+ const r1 = await renewCredentials(ENV, db, []);
+ assert.ok(r1.details.some((d) => d.includes('Cookie 快过期')), '第一轮要提醒');
+ db.settings['renew_last_at'] = '0'; // 放行第二轮
+ db.updates.length = 0;
+ const r2 = await renewCredentials(ENV, db, []);
+ assert.equal(r2.details.length, 0, '第二轮不该再提醒:' + r2.details.join('|'));
+ assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql)), '第二轮不该再写 meta');
+});
+
+await t('到期预警:Cookie 还有 10 天 → 不打扰', async () => {
+ mockFetch([]);
+ globalThis.__calls = [];
+ const db = settingsAwareDb(await cookieAccounts(wpCookie(10 * 86400)));
+ const res = await renewCredentials(ENV, db, []);
+ assert.equal(res.details.length, 0);
+ assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql)), '不该写 meta');
+});
+
+await t('到期预警:已经过期的 Cookie → 也提醒(文案是"已经过期")', async () => {
+ mockFetch([]);
+ const db = settingsAwareDb(await cookieAccounts(wpCookie(-3600)));
+ const res = await renewCredentials(ENV, db, []);
+ assert.ok(res.details.some((d) => d.includes('Cookie 快过期')));
+});
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index efa5131..aa846ad 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -447,13 +447,15 @@ t('鼠标悬浮站点名 → 弹出凭据到期时间', () => {
// 用户最容易卡在「我明明更新了 Cookie,怎么还说失效」——而很多站点把到期时间就写在凭据里
// (WordPress 会话写在 cookie 值里, Akile 的 akile-token 是 JWT)。
// 悬浮站点名直接告诉他「这份还能撑到什么时候」,比等签到失败再查快得多。
- assert.match(html, /
\$\{esc\(a\.name\)\}<\/b>/,
- '站点名要挂气泡');
- assert.match(html, /function credExpTip\(exp\)/, '要有 credExpTip');
+ assert.match(html, /\$\{esc\(a\.name\)\}<\/b>/,
+ '站点名要挂气泡(带账号 meta,好展示自动续期状态)');
+ assert.match(html, /function credExpTip\(exp, metaStr\)/, '要有 credExpTip');
assert.match(html, /const s = \(exp && exp\.sessions\) \|\| \[\];/, '要读服务端解出的 sessions');
assert.match(html, /const t = \(exp && exp\.tokens\) \|\| \[\];/, '也要读 tokens(如 akile-token)');
assert.match(html, /const lines = \[s\.length \? 'Cookie 有效期' : '凭据到期时间'\];/, '有 Cookie 会话就叫「Cookie 有效期」,只有 token 时才叫「凭据到期时间」');
assert.match(html, /这份 Cookie \/ 凭据里没有写到期时间/, '凭据里确实没写时要如实说(不能猜一个时间出来)');
+ assert.match(html, /自动续期:上次成功/, '自动续期成功要在悬浮提示里写出来');
+ assert.match(html, /自动续期失败/, '自动续期失败要在悬浮提示里写出来(带原因和重试说明)');
// 不依赖后端:这一格是 a.cred_exp(服务端只回结论、不回凭据);
// 拖不动的时候连提示都没了,所以要有一条点线底线作暗示
assert.match(html, /\.site-head b\.site-name \{ cursor:help; border-bottom:1px dotted/, '点线底线是可悬浮的唯一暗示');