fix(bangumi-data): 修复 Bangumi Data 开启可能不触发立即下载,并调整云部署数据丢失时下载时机 (#444)

* fix: Bangumi Data 按需下载下沉至消费路径并修复配置变更未触发下载与漏写 await

将 Bangumi Data 初始化从 worker.js 网关层整体移除,下沉为 searchBangumiData
消费路径上的按需下载触发(ensureBangumiDataReady):仅在真正读取缓存的路径
(4 源搜索/匹配/弹幕 + 全局跨季边界)触发,未配置消费源的部署(如仅 renren)
不会下载,云部署丢失内存时由数据请求重新触发。

新增 syncBangumiDataLifecycleOnConfigChange,在 USE_BANGUMI_DATA 由关到开时
立即触发一次下载、由开到关时释放缓存;接入 env-api 三处 handler 与 server.js
的 .env 监听器,覆盖前端保存与本地直接改文件两种启用路径。

修复 tmdb-util.js getTMDBChineseTitle 调用 searchBangumiData 漏写 await,使本地
Bangumi Data 中文名命中路径真实生效(此前 localMatches 为非同步 Promise、命中
恒不生效)。

移除无调用方的 ctx 参数(handleRequest 与 initBangumiData):上游在 fetch 边缘层
经数据层 ctx.waitUntil 延长后台下载,本分支将下载触发下沉至消费路径后不再深穿 ctx,
该参数及依赖它的 ctx.waitUntil 分支已无调用来源,移除以避免无调用来源的防御性代码。

* fix: 暴露 Bangumi Data 在途后台下载并由边缘运行时延长生命周期

下载触发下沉至消费路径后不再将 ctx 深穿至数据层,上游原本由数据层 ctx.waitUntil
延长的后台下载在边缘层失去接线,导致边缘 isolate 在响应返回后冻结会中断在途的
后台静默下载(仅影响刷新效率,不阻塞请求,靠幽灵死锁自愈兜底)。本提交改为在边缘层
暴露在途下载并延长生命周期:

- initBangumiData 发起非阻塞刷新时把在途下载 Promise 写入模块级 currentBackgroundDownload,
  下载完成即在 finally 中复位;新增 getBackgroundDownload 暴露该状态。
- 新增 extendBangumiDownloadLifecycle(ctx),仅在存在在途下载且运行时提供 waitUntil 时
  注册,供边缘层在响应返回后延长 isolate 生命周期以容纳下载;无 ctx 的运行时自然 no-op。
- worker.js 的 Cloudflare Workers fetch 入口重新接收 ctx 参数,await handleRequest 后调用
  extendBangumiDownloadLifecycle(ctx);vercel/netlify 入口无 ctx,不受影响。
- 集中下载启动逻辑为 startDownload,三处刷新分支复用,消除重复的锁与复位代码。

* chore: Bangumi Data 模块级 let 声明整理为末尾注释并合并分组空行

将 currentBackgroundDownload、activeDataSource、cachedCustomVersion 的独立注释
行改为声明末尾注释,与既有的 versionQueryPromise 风格统一;删除 data-source 组内
cachedOfficialVersion 与 versionQueryPromise 之间的多余空行,保留 cache 组与
data-source 组之间的逻辑分隔空行,使模块顶部状态声明更紧凑一致。

* fix: 补全 bangumi 测试注释块缺失的导入符号

测试注释块第一组的 bangumi-data-util 导入仅含 ensureBangumiDataReady 与
syncBangumiDataLifecycleOnConfigChange,但同组用例还使用了 initBangumiData 与
clearBangumiDataCache,解除注释后会因未导入而 ReferenceError。getTMDBChineseTitle 与
assert 已由 worker.test.js 顶部全局导入,无需重复。补全缺失的两个符号,使该注释块
解除注释即可经 node --test 运行,并已在沙箱真实下载下验证通过。

---------

Co-authored-by: huangxd- <649924757@qq.com>
This commit is contained in:
Elegy233andhuangxd- authored and GitHub committed 2026-08-15 08:06:38 +08:00
1 parent 7e5c8073e0
commit 9bfca7a8e9
6 files changed
+170 -36

No files matched your search

+13
View File
@@ -2,6 +2,7 @@ import { jsonResponse } from '../utils/http-util.js';
import { log } from '../utils/log-util.js';
import { HandlerFactory } from '../configs/handlers/handler-factory.js';
import { globals } from '../configs/globals.js';
import { syncBangumiDataLifecycleOnConfigChange } from '../utils/bangumi-data-util.js';
import AIClient from '../utils/ai-util.js';
/**
@@ -24,6 +25,10 @@ export async function handleSetEnv(request) {
// 调用handler的setEnv方法
const result = await handler.setEnv(key, value);
if (result && key === 'USE_BANGUMI_DATA') {
syncBangumiDataLifecycleOnConfigChange(deployPlatform);
}
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 设置成功` });
} else {
@@ -55,6 +60,10 @@ export async function handleAddEnv(request) {
// 调用handler的addEnv方法
const result = await handler.addEnv(key, value);
if (result && key === 'USE_BANGUMI_DATA') {
syncBangumiDataLifecycleOnConfigChange(deployPlatform);
}
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 添加成功` });
} else {
@@ -86,6 +95,10 @@ export async function handleDelEnv(request) {
// 调用handler的delEnv方法
const result = await handler.delEnv(key);
if (result && key === 'USE_BANGUMI_DATA') {
syncBangumiDataLifecycleOnConfigChange(deployPlatform);
}
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 删除成功` });
} else {
+7 -6
View File
@@ -9,9 +9,9 @@ import { HttpsProxyAgent } from 'https-proxy-agent';
import dotenv from 'dotenv';
import { Request as NodeFetchRequest } from 'node-fetch';
import { handleRequest } from './worker.js';
import { Globals } from './configs/globals.js';
import { Globals, globals } from './configs/globals.js';
import { Envs } from './configs/envs.js';
import { clearBangumiDataCache, initBangumiData } from './utils/bangumi-data-util.js';
import { clearBangumiDataCache, initBangumiData, syncBangumiDataLifecycleOnConfigChange } from './utils/bangumi-data-util.js';
import { getLocalCaches, judgeLocalCacheValid } from './utils/cache-util.js';
import { getRedisCaches, judgeRedisValid } from './utils/redis-util.js';
import { persistFavorites, refreshFavoriteByKeyword } from './apis/favorite-api.js';
@@ -234,10 +234,11 @@ async function setupEnvWatcher() {
console.log('[server] Environment variables reloaded successfully');
console.log('[server] Updated keys:', Array.from(newEnvKeys).join(', '));
// 如果检测到关闭了 Bangumi Data 功能,主动释放内存与物理磁盘缓存
if (process.env.USE_BANGUMI_DATA === 'false' || process.env.USE_BANGUMI_DATA === false) {
clearBangumiDataCache(true);
}
// 配置变更后同步 Bangumi Data 生命周期:开启则立即确保缓存就绪(缺失则下载),
// 关闭则释放缓存;先按真实配置同步内存开关,避免重载未刷新 globals 时状态滞后
const bangumiEnabled = process.env.USE_BANGUMI_DATA === 'true' || process.env.USE_BANGUMI_DATA === true;
globals.useBangumiData = bangumiEnabled;
syncBangumiDataLifecycleOnConfigChange('node');
} catch (error) {
console.log('[server] Error reloading configuration files:', error.message);
+48 -19
View File
@@ -18,13 +18,11 @@ let downloadLockTime = 0;
let memoryFootprintMB = '0.00';
let hasLoggedCacheWarning = false;
let charInvertedIndex = new Map();
let currentBackgroundDownload = null; // 在途后台下载 Promise:由 initBangumiData 在发起非阻塞刷新时写入,供边缘层 ctx.waitUntil 延长 Serverless 生命周期
// 当前生效的数据源标识 ('custom' | 'official')
let activeDataSource = 'custom';
// 缓存已解析的版本号(进程内有效)
let cachedCustomVersion = null;
let activeDataSource = 'custom'; // 当前生效的数据源标识 ('custom' | 'official')
let cachedCustomVersion = null; // 缓存已解析的版本号(进程内有效)
let cachedOfficialVersion = null;
let versionQueryPromise = null; // 版本查询并发锁:serverless环境下冻结时异步信号可能不生效,共享同一Promise避免重复探测
// 定义缓存目录/文件名
@@ -193,6 +191,46 @@ async function selectBestDataSource() {
return versionQueryPromise;
}
// 数据下载触发时机:仅在 searchBangumiData 消费路径进入时触发下载;开关关闭或无所消费源时不下载
// 开启时复用 initBangumiData 加载/下载,并发调用等待同一就绪缓存而不重复请求
export async function ensureBangumiDataReady(deployPlatform = globals.deployPlatform) {
if (!globals.useBangumiData) return;
await initBangumiData(deployPlatform, true);
}
// 配置变更后同步 Bangumi Data 生命周期:开关开启立即下载(已缓存则幂等),关闭释放缓存
// 调用前须保证 globals.useBangumiData 已反映最新配置(watcher 场景须先同步内存开关)
export function syncBangumiDataLifecycleOnConfigChange(deployPlatform = globals.deployPlatform) {
if (globals.useBangumiData) {
initBangumiData(deployPlatform, true).catch(console.error);
} else {
clearBangumiDataCache(true);
}
}
// 读取在途后台下载 Promise,供边缘运行时在请求响应后延长生命周期(无在途时返回 null)
export function getBackgroundDownload() {
return currentBackgroundDownload;
}
// 边缘运行时在响应返回后延长生命周期:仅在存在在途后台下载且运行时提供 waitUntil 时注册
export function extendBangumiDownloadLifecycle(ctx) {
if (ctx && typeof ctx.waitUntil === 'function' && currentBackgroundDownload) {
ctx.waitUntil(currentBackgroundDownload);
}
}
// 发起后台静默下载并记录在途 Promise:下载完成时复位状态,供 getBackgroundDownload 暴露给边缘层
function startDownload(cachePath) {
isDownloading = true;
downloadLockTime = Date.now();
currentBackgroundDownload = downloadAndCache(cachePath).finally(() => {
isDownloading = false;
currentBackgroundDownload = null;
});
return currentBackgroundDownload;
}
/**
* 初始化 Bangumi Data 数据源
* 包含内存与磁盘双端缓存的生命周期校验,并在环境允许时持久化缓存数据
@@ -201,7 +239,7 @@ async function selectBestDataSource() {
* @param {boolean} isDataDependentRequest - 当前是否为强依赖数据的核心接口请求
* @returns {Promise<void>}
*/
export async function initBangumiData(deployPlatform, isDataDependentRequest = false, ctx = null) {
export async function initBangumiData(deployPlatform, isDataDependentRequest = false) {
if (!globals.useBangumiData) return;
let cachePath = null;
@@ -237,9 +275,7 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f
// 当内存过期或配置强制更新时,仅在核心请求触发后台静默更新
if (isDataDependentRequest && !isDownloading) {
log("info", `[system] [Bangumi-Data] 内存数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`);
isDownloading = true;
downloadLockTime = Date.now();
downloadAndCache(cachePath).finally(() => { isDownloading = false; });
startDownload(cachePath);
}
return;
}
@@ -274,9 +310,7 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f
if (cacheDays === 0 || (Date.now() - stats.mtimeMs >= expireMs)) {
if (isDataDependentRequest && !isDownloading) {
log("info", `[system] [Bangumi-Data] 磁盘数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`);
isDownloading = true;
downloadLockTime = Date.now();
downloadAndCache(cachePath).finally(() => { isDownloading = false; });
startDownload(cachePath);
}
}
return;
@@ -289,19 +323,13 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f
// 内存与磁盘均无有效数据时的获取逻辑
if (!isDownloading) {
log("info", `[system] [Bangumi-Data] 未命中任何有效缓存,正在获取基础数据...`);
isDownloading = true;
downloadLockTime = Date.now();
const downloadPromise = downloadAndCache(cachePath).finally(() => { isDownloading = false; });
const downloadPromise = startDownload(cachePath);
if (isDataDependentRequest) {
await downloadPromise;
} else {
log("info", `[system] [Bangumi-Data] 当前非核心请求,数据获取转入后台异步执行`);
if (ctx && typeof ctx.waitUntil === 'function') {
log("info", `[system] [Bangumi-Data] 调用 ctx.waitUntil 延长 Serverless 生命周期`);
ctx.waitUntil(downloadPromise);
}
}
} else if (isDataDependentRequest) {
log("info", `[system] [Bangumi-Data] 正在等待基础数据下载完成...`);
@@ -503,6 +531,7 @@ async function downloadAndCache(cachePath) {
* @returns {Array<Object>} 匹配的动漫条目数组
*/
export async function searchBangumiData(keyword, siteKeys) {
await ensureBangumiDataReady();
if (!memoryCache || !memoryCache.items) return [];
let searchPromise = queryCache.get(keyword);
+1 -1
View File
@@ -593,7 +593,7 @@ export async function getTMDBChineseTitle(title, season = null, episode = null)
// 优先尝试本地 Bangumi Data 转换
if (globals.useBangumiData) {
const cleanTitle = cleanSearchQuery(title);
const localMatches = searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']);
const localMatches = await searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']);
if (localMatches && localMatches.length > 0) {
const m = localMatches[0];
// 找一个不全是外文的翻译作为中文名
+6 -10
View File
@@ -5,13 +5,13 @@ import { getFavoriteCachesFromRedis, getRedisCaches, judgeRedisValid } from "./u
import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js";
import { formatDanmuResponse } from "./utils/danmu-util.js";
import AIClient from './utils/ai-util.js';
import { initBangumiData } from "./utils/bangumi-data-util.js";
import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
import { handleFavoriteAdd, handleFavoriteList, handleFavoriteRefresh, handleFavoriteRemove, handleFavoriteSchedule } from "./apis/favorite-api.js";
import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords, handleCacheAnimes } from "./apis/system-api.js";
import { handleForwardTrace } from "./apis/forward-trace-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js";
import { extendBangumiDownloadLifecycle } from "./utils/bangumi-data-util.js";
import { Segment } from "./models/dandan-model.js"
import {
handleCookieStatus,
@@ -23,7 +23,7 @@ import {
let globals;
async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
async function handleRequest(req, env, deployPlatform, clientIp) {
// 加载全局变量和环境变量配置
globals = Globals.init(env);
@@ -31,13 +31,6 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
let path = url.pathname;
const method = req.method;
// Bangumi Data 辅助函数,用于判断数据更新
const isDataDependentRequest = path.includes('/search') || path.includes('/match') || path.includes('/danmaku');
if (globals.useBangumiData) {
await initBangumiData(deployPlatform, isDataDependentRequest, ctx);
}
globals.deployPlatform = deployPlatform;
if (deployPlatform === "node") {
await judgeLocalCacheValid(path, deployPlatform);
@@ -754,7 +747,10 @@ export default {
// 获取客户端的真实 IP
const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
return handleRequest(request, env, detectDeployPlatform(env), clientIp, ctx);
const response = await handleRequest(request, env, detectDeployPlatform(env), clientIp);
// 边缘运行时在响应返回后延长生命周期,容纳可能在途的 Bangumi Data 后台静默下载
extendBangumiDownloadLifecycle(ctx);
return response;
},
};
+95
View File
@@ -2850,6 +2850,101 @@ test('worker.js API endpoints', async (t) => {
// });
// });
// // 测试 Bangumi Data 数据下载时机(ensureBangumiDataReady)、配置变更触发下载(syncBangumiDataLifecycleOnConfigChange)
// // 以及 getTMDBChineseTitle 漏写 await 的修复;与 envs RAW_ENV_KEYS 测试同为按需启用的内部测试
// import { globals } from './configs/globals.js';
// import { ensureBangumiDataReady, syncBangumiDataLifecycleOnConfigChange, initBangumiData, clearBangumiDataCache } from './utils/bangumi-data-util.js';
// import fs from 'node:fs';
// import path from 'node:path';
//
// test('bangumi-data 数据下载时机与配置变更触发下载', async (t) => {
// const CACHE_DIR = path.join(process.cwd(), '.cache');
// const CACHE_FILE = path.join(CACHE_DIR, 'bangumi-data-cache.json');
// const FAKE_ITEM = {
// title: 'FrobeniusTestAnime',
// titleTranslate: { 'zh-Hans': ['弗罗贝尼乌斯测试动画', 'FrobeniusTestAnime'] },
// sites: [{ site: 'tmdb', id: '999999' }],
// _flatText: 'frobeniustestanime'
// };
// const reset = () => {
// globals.useBangumiData = false;
// clearBangumiDataCache(false);
// if (fs.existsSync(CACHE_FILE)) fs.writeFileSync(CACHE_FILE, '', 'utf-8');
// };
//
// await t.test('ensureBangumiDataReady 开关关闭时直接返回且不触发下载', async () => {
// reset();
// globals.useBangumiData = false;
// await ensureBangumiDataReady('node');
// assert.ok(true);
// });
//
// await t.test('syncBangumiDataLifecycleOnConfigChange 开关关闭释放缓存、开启安全触发', async () => {
// reset();
// globals.useBangumiData = false;
// assert.doesNotThrow(() => syncBangumiDataLifecycleOnConfigChange('node'));
// fs.mkdirSync(CACHE_DIR, { recursive: true });
// fs.writeFileSync(CACHE_FILE, JSON.stringify({ items: [FAKE_ITEM] }), 'utf-8');
// globals.useBangumiData = true;
// assert.doesNotThrow(() => syncBangumiDataLifecycleOnConfigChange('node'));
// });
//
// await t.test('getTMDBChineseTitle 经 await 命中本地中文名(修复漏写 await)', async () => {
// reset();
// globals.useBangumiData = true;
// const originalContent = fs.existsSync(CACHE_FILE) ? fs.readFileSync(CACHE_FILE, 'utf-8') : null;
// fs.mkdirSync(CACHE_DIR, { recursive: true });
// fs.writeFileSync(CACHE_FILE, JSON.stringify({ items: [FAKE_ITEM] }), 'utf-8');
// try {
// await initBangumiData('node', true);
// const result = await getTMDBChineseTitle('FrobeniusTestAnime');
// assert.equal(result, '弗罗贝尼乌斯测试动画');
// } finally {
// clearBangumiDataCache(false);
// if (originalContent !== null) fs.writeFileSync(CACHE_FILE, originalContent, 'utf-8');
// else fs.writeFileSync(CACHE_FILE, '', 'utf-8');
// }
// });
// });
// // 测试 Bangumi Data 在途下载暴露与边缘生命周期延长(getBackgroundDownload / extendBangumiDownloadLifecycle)
// // 与上方 bangumi 测试同为按需启用的内部测试;沙箱有网时真实下载以验证在途暴露、注册与清理
// import { globals } from './configs/globals.js';
// import { initBangumiData, getBackgroundDownload, extendBangumiDownloadLifecycle } from './utils/bangumi-data-util.js';
// import assert from 'node:assert';
// import fs from 'node:fs';
// import path from 'node:path';
//
// test('bangumi-data 在途下载暴露与边缘生命周期延长', async (t) => {
// const CACHE_DIR = path.join(process.cwd(), '.cache');
// const CACHE_FILE = path.join(CACHE_DIR, 'bangumi-data-cache.json');
// const hadCache = fs.existsSync(CACHE_DIR);
//
// // 空闲时无在途下载
// assert.strictEqual(getBackgroundDownload(), null);
//
// await t.test('extendBangumiDownloadLifecycle 在无在途或 ctx 缺失时不注册', async () => {
// const calls = [];
// extendBangumiDownloadLifecycle(null);
// extendBangumiDownloadLifecycle({ waitUntil: (p) => calls.push(p) });
// assert.strictEqual(calls.length, 0);
// });
//
// await t.test('在途下载被暴露、响应后由边缘 waitUntil 注册、完成后清理', async () => {
// globals.useBangumiData = true;
// // 启动真实下载(无 .cache 时走内存路径,不落地文件;有 .cache 则后台刷新),不在途时立即返回
// const initPromise = initBangumiData('node', true);
// const bg = getBackgroundDownload();
// assert.ok(bg && typeof bg.then === 'function', '下载在途时应暴露 Promise');
// const ctx = { waitUntil: (p) => { ctx.registered = p; } };
// extendBangumiDownloadLifecycle(ctx);
// assert.strictEqual(ctx.registered, bg, '边缘 waitUntil 应注册在途 Promise');
// await bg; // 等待下载完成(兼容阻塞与后台两种路径)
// assert.strictEqual(getBackgroundDownload(), null, '下载完成后应清理在途状态');
// globals.useBangumiData = false;
// if (!hadCache && fs.existsSync(CACHE_FILE)) fs.writeFileSync(CACHE_FILE, '', 'utf-8');
// await initPromise.catch(() => {});
// });
// // 测试自定义文本类变量绕过 dotenv 注释截断(保留 # 等字符),对应 envs.js RAW_ENV_KEYS 修复
// import { Envs } from './configs/envs.js';
//