release: v2.20.0 - 后台凭据自动续期 + Cookie 到期预警

This commit is contained in:
hani committed 2026-10-01 10:18:41 +08:00
1 parent eb1f8b4ac3
commit 37584baa09
10 files changed
+582 -17

No files matched your search

+10
View File
@@ -14,6 +14,16 @@
### 修复
-
## [2.20.0] - 2026-10-01
### 新增
- 后台凭据自动续期:独立于签到流程,调度器每小时扫一遍账号,做两件事——① 有 renew() 的站点(如 Akile):快过期的凭据提前换新并回写(Akile 先接入:到期前 12 小时触发);② 纯 Cookie 的站点(没有刷新接口、面板手里也没密码,技术上续不了):凭据里写着到期时间的(WordPress 会话 / JWT),剩不到 24 小时就提前预警,提醒去重新抓(每天每个账号一次)。天翼云盘、V2Board 存的是账号密码、每次签到本来就是重新登录,天生不会"过期",不需要续。
- 续期走「扩展在线先走用户本地网络、再直连兜底」两条路线;只有「网络失败」才换路线重试,「站点拒绝」直接记原因不再空试。
- 续期状态可视化:账号站点名悬浮提示新增「自动续期:上次成功 …」/「自动续期失败(原因 + 详情 + 每小时重试说明)」;失败且凭据剩不到 6 小时就过期时推送提醒(每天一次),不等死了才让用户手动。
### 修复
- Akile 续期失败原因不再「一律吞掉」:区分「网络没通」(值得重试)和「站点拒绝」(旧 token 已死),后台续期失败会记下具体原因(HTTP 状态 + 网站回馈),而不是第二天莫名其妙报「请重新获取」。签到主流程的报错文案保持不变。
## [2.19.1] - 2026-09-30
### 新增
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "daily-checkin-panel",
"version": "2.19.1",
"version": "2.20.0",
"private": true,
"description": "跑在 Cloudflare Workers 上的自动签到面板:网页后台管账号 + D1 存储 + 定时签到 + 推送通知",
"type": "module",
+14 -2
View File
@@ -2751,7 +2751,7 @@ const SITE_MODE_HINT_DOCKER = {
// 两种都报出来,用户就不用猜「我这份 Cookie 还能撑几天」。
// 其它站点(Discuz、机场面板…)的会话有效期只在服务器那边知道 —— 这里**如实说「凭据里没写」**,
// 不编一个时间出来:他看这句话是为了决定「要不要现在去重新登录」,猜错比不说更坏。
function credExpTip(exp) {
function credExpTip(exp, metaStr) {
const fmt = (ms) => new Date(ms).toLocaleString('zh-CN',
{ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const left = (ms) => {
@@ -2776,6 +2776,18 @@ function credExpTip(exp) {
lines.push('这份 Cookie / 凭据里没有写到期时间 —— 只有网站自己知道它还算不算登录。');
lines.push('真失效了面板会报「需要重新登录」,也能用「Cookie 解析器」当场体检。');
}
// ---- 自动续期状态(后台每小时检查,到期前自动换新;Akile 这类短命 token 就靠它续命)----
let rm = {};
try { rm = JSON.parse(metaStr || '{}'); } catch { /* 忽略 */ }
if (rm.renew_ok_at) {
lines.push('自动续期:上次成功 ' + fmt(rm.renew_ok_at) + '(面板自己换的新凭据,你不用管)');
}
if (rm.renew_fail_at && (!rm.renew_ok_at || rm.renew_fail_at > rm.renew_ok_at)) {
const reasonMap = { network: '网络没通', rejected: '站点拒绝(旧凭据已失效)', invalid: '凭据格式不对', error: '续期过程出错' };
lines.push('自动续期失败(' + fmt(rm.renew_fail_at) + '):' + (reasonMap[rm.renew_fail_reason] || rm.renew_fail_reason || '未知原因'));
if (rm.renew_fail_detail) lines.push('失败详情:' + rm.renew_fail_detail);
lines.push('面板会每小时重试一次;如果一直失败,请手动重新获取凭据。');
}
return lines.join('\n');
}
@@ -2833,7 +2845,7 @@ function execCellHTML(a, execOverride) {
return {
isLocalExec,
siteHint: hint,
html: `<div class="site-wrap"><div class="site-head"><b class="site-name" data-tip="${esc(credExpTip(a.cred_exp))}">${esc(a.name)}</b>${comBadge}`
html: `<div class="site-wrap"><div class="site-head"><b class="site-name" data-tip="${esc(credExpTip(a.cred_exp, a.meta))}">${esc(a.name)}</b>${comBadge}`
+ `<button class="ghost sm exec-switch" data-exec="${a.id}" title="${esc(execTitle)}">切换</button>`
+ `</div><div class="site-line">${execBadge}</div>${bsign}${pills}</div>`,
};
+15
View File
@@ -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 写入。
+208
View File
@@ -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;
}
+3
View File
@@ -44,6 +44,9 @@ function withAccountLock(fn) {
export function accountLockIdle() {
return ACCOUNT_LOCK;
}
// 凭据续期(src/lib/renew.js)与签到共用同一把账号锁:同一账号的签到和续期
// 不能同时跑(都会读写它的凭据)。
export { withAccountLock };
// 仅供测试:Set-Cookie 静默回写(生产代码走 runAccount 内部调用)
export { applyCookieRefresh };
+53 -10
View File
@@ -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', // 浏览器执行时的目标域名
+1 -1
View File
@@ -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';
+272
View File
@@ -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 快过期')));
});
+5 -3
View File
@@ -447,13 +447,15 @@ t('鼠标悬浮站点名 → 弹出凭据到期时间', () => {
// 用户最容易卡在「我明明更新了 Cookie,怎么还说失效」——而很多站点把到期时间就写在凭据里
// (WordPress 会话写在 cookie 值里, Akile 的 akile-token 是 JWT)。
// 悬浮站点名直接告诉他「这份还能撑到什么时候」,比等签到失败再查快得多。
assert.match(html, /<b class="site-name" data-tip="\$\{esc\(credExpTip\(a\.cred_exp\)\)\}">\$\{esc\(a\.name\)\}<\/b>/,
'站点名要挂气泡');
assert.match(html, /function credExpTip\(exp\)/, '要有 credExpTip');
assert.match(html, /<b class="site-name" data-tip="\$\{esc\(credExpTip\(a\.cred_exp, a\.meta\)\)\}">\$\{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/, '点线底线是可悬浮的唯一暗示');