From ee136b65288407344208722a51acb0961d2bd028 Mon Sep 17 00:00:00 2001 From: Pikachu Bot Date: Sun, 11 Oct 2026 13:53:24 +0800 Subject: [PATCH 1/3] fix(139,fs): throw on missing folder path segment; don't cache Virtual results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. drivers/139/driver.ts — resolveCatalogId() used `break` when a path segment was not found in the folder listing, silently returning the last successfully resolved catalogId (typically the root). list() and get() then appeared to succeed but returned root directory contents for any path whose intermediate folder could not be matched, producing the 'always shows root' symptom. Changed to `throw` with a message that includes the missing segment and full path so misconfigured account types are immediately visible in logs. 2. server/fs.ts — when resolvePath() returns isVirtual=true the storage object is null and cacheExpiration was set to `undefined`. The front end interprets undefined as a non-zero TTL and caches the Virtual result; subsequent requests hit the cache and keep showing the Virtual provider even after the real storage comes online. Fixed by returning 0 for Virtual paths so the front end never caches them. --- src/backend/drivers/139/driver.ts | 4 +++- src/backend/server/fs.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index 70241b42..78f7e33c 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -50,7 +50,9 @@ export class Yun139Driver implements StorageDriver { if (foundFolder) { currentCatalogId = foundFolder.catalogID } else { - break + throw new Error( + `[139] folder not found: "${part}" in path "${physicalPath}"`, + ) } } diff --git a/src/backend/server/fs.ts b/src/backend/server/fs.ts index ac90cef6..c35cb812 100644 --- a/src/backend/server/fs.ts +++ b/src/backend/server/fs.ts @@ -440,7 +440,9 @@ fsRouter.post("/list", async (c) => { // 对齐 Go 的路径级缓存策略;前端/上层可据此决定该目录的列表缓存时长。 const cacheExpiration = storage ? resolveCacheExpiration(storage, reqPath) - : undefined + : 0 // Virtual directories must not be cached; a cached Virtual result + // would persist after the real storage comes online, causing the + // 'always shows root / Virtual provider' symptom for subsequent requests. return c.json({ code: 200, message: "success", From 902aae8b5b21df7bc83ec39c299b703b6de032ec Mon Sep 17 00:00:00 2001 From: pikachuren Date: Fri, 16 Oct 2026 16:32:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(139):=20=E8=B7=AF=E7=94=B1=20host=20?= =?UTF-8?q?=E7=A1=AC=E5=A4=B1=E8=B4=A5=20+=20=E7=9B=AE=E5=BD=95=20id=20?= =?UTF-8?q?=E5=B9=B6=E9=9B=86=E5=9B=9E=E9=80=80=20+=20=E5=80=99=E9=80=89?= =?UTF-8?q?=20URL=20=E9=87=8D=E8=AF=95=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #121 把 resolveCatalogId 的 break 改为 throw 后问题依旧,因为还有三条 绕过 throw 的静默回退路径: 1) init() 捕获 queryRoutePolicy 失败后仅告警,personalHost 停在默认域 yun.139.com。新版个人云的 /file/list 只存在于路由下发的 host 上,打到 默认域会命中旧版端点:该端点忽略 parentFileId、恒返回根目录内容,且 返回 HTTP 200 + success:true —— 按名解析目录因此仍然成功,任何 throw 都不会被触发,表现为访问子目录却看到根目录全部文件夹,点击文件又出现 /手机软件/阅读.apk/阅读.apk 自我嵌套。现对齐 Go 官方驱动 ensurePersonalCloudHost:personal_new 拿不到路由 host 即初始化失败; 旧版个人云走 /orchestration 硬编码路径,不依赖路由,仍只告警。 2) /file/list 目录项在部分账号/响应形态下以 catalogId|catalogID 携带 id 而非 fileId(社区实现 gaozhangmin/boxplayer 的字段映射可佐证)。只读 fileId 会拿到 undefined,listFiles(undefined) 回退 parentFileId="/" 静默列出根目录。现改为并集回退,并在任何 id 都拿不到时显式抛错。 3) 路由下发的 host 可能带 /hcy 也可能只暴露 /orchestration,request() 此前只有单一 URL。现按候选顺序尝试,仅 404 / "route not found" 才回退, 其余错误原样抛出并附带请求 URL。 顺带:目录名比较改为 trim + NFC 归一化(中文名跨归一化形态不再误判为 不存在);分页游标沿用 Go 官方确认的 data.nextPageCursor,并防御游标 不前进导致的死循环。 新增 6 组回归测试(覆盖 issue #112 两个症状、init 硬失败、id 并集回退、 Unicode 归一化、候选 URL 重试语义),9/9 通过。 --- src/backend/drivers/139/driver.test.ts | 416 ++++++++++++++++++++++++- src/backend/drivers/139/driver.ts | 43 ++- src/backend/drivers/139/types.ts | 9 +- src/backend/drivers/139/util.ts | 186 +++++++++-- 4 files changed, 609 insertions(+), 45 deletions(-) diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index 93c6abad..e80ea80f 100644 --- a/src/backend/drivers/139/driver.test.ts +++ b/src/backend/drivers/139/driver.test.ts @@ -59,7 +59,6 @@ test("Yun139Driver instantiation and methods", async () => { assert.equal(link.url, "https://download.yun.139.com/photo.jpg") }) - test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { const expiresAt = Date.now() + 5 * 24 * 60 * 60 * 1000 const oldToken = `old-token|1|RCS|${expiresAt}|opaque` @@ -71,11 +70,15 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { type: "personal_new", } // Simulate a legacy config containing PC cookies; the refresh request must ignore them. - ;(addition as any).pc_cloud_cookies = "mc_at=must-not-send; mc_bt=must-not-send" + ;(addition as any).pc_cloud_cookies = + "mc_at=must-not-send; mc_bt=must-not-send" const originalFetch = globalThis.fetch const calls: Array<{ url: string; init?: RequestInit }> = [] - globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { const url = String(input) calls.push({ url, init }) @@ -97,11 +100,20 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { } if (url === "https://user-njs.yun.139.com/user/route/qryRoutePolicy") { + // personal_new 的 init() 现在对"拿不到路由 personal host"硬失败 + // (issue #112 根因修复),mock 必须下发一个非默认域的 host。 return new Response( JSON.stringify({ success: true, code: "0000", - data: { routePolicyList: [] }, + data: { + routePolicyList: [ + { + modName: "personal", + httpsUrl: "https://personal-kd-njs.yun.139.com/hcy", + }, + ], + }, }), { status: 200, headers: { "content-type": "application/json" } }, ) @@ -117,14 +129,23 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { }) await client.init() - assert.equal(calls[0]?.url, "https://user-njs.yun.139.com/user/auth/refreshToken") + assert.equal( + calls[0]?.url, + "https://user-njs.yun.139.com/user/auth/refreshToken", + ) assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), { userDomainId: "test-user-domain", }) const sentHeaders = calls[0]?.init?.headers as Record - assert.equal(sentHeaders.authorization, `Basic ${Buffer.from(`pc:${account}:${oldToken}`).toString("base64")}`) - assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "cookie"), false) + assert.equal( + sentHeaders.authorization, + `Basic ${Buffer.from(`pc:${account}:${oldToken}`).toString("base64")}`, + ) + assert.equal( + Object.keys(sentHeaders).some((key) => key.toLowerCase() === "cookie"), + false, + ) assert.equal(calls[0]?.init?.credentials, "omit") assert.equal(sentHeaders.app_cp, "1") assert.equal(sentHeaders["x-yun-api-version"], "v1") @@ -132,8 +153,16 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { assert.equal(sentHeaders["x-yun-market-source"], "1") assert.equal(sentHeaders["x-yun-module-type"], "1") assert.equal(sentHeaders["x-yun-client-info"], "PC") - assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "x-yun-uni"), false) - assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "x-deviceinfo"), false) + assert.equal( + Object.keys(sentHeaders).some((key) => key.toLowerCase() === "x-yun-uni"), + false, + ) + assert.equal( + Object.keys(sentHeaders).some( + (key) => key.toLowerCase() === "x-deviceinfo", + ), + false, + ) assert.equal( Buffer.from(addition.authorization, "base64").toString("utf8"), @@ -144,3 +173,372 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { globalThis.fetch = originalFetch } }) + +test("Yun139Driver lists subdir contents; file path must not degrade to parent (issue #112)", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const driver = new Yun139Driver(addition) + const table: Record = { + "/": { + folders: [{ catalogID: "d_soft", catalogName: "手机软件" }], + files: [ + { contentID: "f_root", contentName: "root.txt", contentSize: 10 }, + ], + }, + d_soft: { + folders: [], + files: [ + { contentID: "f_apk", contentName: "阅读.apk", contentSize: 20485008 }, + ], + }, + } + ;(driver as any).client.listFiles = async (catalogId: string) => + table[catalogId] || { folders: [], files: [] } + + // 根目录正常列出 + const rootItems = await driver.list("/", "/") + assert.deepEqual( + rootItems.map((i) => i.name), + ["手机软件", "root.txt"], + ) + + // 症状1:访问子目录必须返回子目录内容,而不是根目录内容 + const subItems = await driver.list("/手机软件", "/手机软件") + assert.deepEqual( + subItems.map((i) => i.name), + ["阅读.apk"], + ) + + // 症状2(套娃):文件路径不能被当作目录列出父级内容 + await assert.rejects( + () => driver.list("/手机软件/阅读.apk", "/手机软件/阅读.apk"), + /folder not found/, + ) + + // 不存在的目录必须显式报错,而不是静默回退到根目录 + await assert.rejects( + () => driver.list("/不存在的目录", "/不存在的目录"), + /folder not found/, + ) +}) + +test("Yun139Driver name matching tolerates Unicode normalization differences", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const driver = new Yun139Driver(addition) + const nfdName = "café" // NFD:e + 组合重音 + const nfcPath = "/café" // NFC:预组合 é + const table: Record = { + "/": { + folders: [{ catalogID: "d_cafe", catalogName: nfdName }], + files: [], + }, + d_cafe: { + folders: [], + files: [{ contentID: "f", contentName: "inside.txt", contentSize: 1 }], + }, + } + ;(driver as any).client.listFiles = async (catalogId: string) => + table[catalogId] || { folders: [], files: [] } + + const items = await driver.list(nfcPath, nfcPath) + assert.deepEqual( + items.map((i) => i.name), + ["inside.txt"], + ) +}) + +test("Yun139 personal_new list follows data.nextPageCursor pagination", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + + const originalFetch = globalThis.fetch + const bodies: any[] = [] + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body)) + bodies.push(body) + const cursor = String(body.pageInfo?.pageCursor || "") + const page = + cursor === "" + ? [ + { fileId: "f1", name: "a.txt", type: "file", size: 1 }, + { fileId: "d1", name: "子目录", type: "folder" }, + ] + : [{ fileId: "f2", name: "b.txt", type: "file", size: 2 }] + const next = cursor === "" ? "CUR1" : "" + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { items: page, nextPageCursor: next }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const client = new Yun139ApiClient(addition) + // 本用例绕过 init:personal_new 禁止使用默认域,显式给定路由 host + client.personalHost = "https://personal-kd-njs.yun.139.com/hcy" + const res = await client.listFiles("/") + assert.equal(bodies.length, 2) + assert.equal(bodies[0].pageInfo.pageCursor, "") + assert.equal(bodies[1].pageInfo.pageCursor, "CUR1") + assert.deepEqual( + res.files.map((f) => f.contentName), + ["a.txt", "b.txt"], + ) + assert.deepEqual( + res.folders.map((f) => f.catalogName), + ["子目录"], + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 folder id falls back to catalogId/catalogID; missing id fails loudly (issue #112)", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const originalFetch = globalThis.fetch + let mode: "union" | "noid" = "union" + globalThis.fetch = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + const body = JSON.parse(String(init?.body)) + const parent = String(body.parentFileId || "") + let items: any[] = [] + if (mode === "union") { + // 目录项没有 fileId,只带 catalogId(小写 d)——社区实现 + // gaozhangmin/boxplayer 的字段并集回退要覆盖的目标形态 + items = + parent === "/" + ? [ + { + catalogId: "d_soft", + name: "手机软件", + type: "folder", + updatedAt: "2026-10-04T19:47:40.056+08:00", + }, + ] + : parent === "d_soft" + ? [ + { + fileId: "f_apk", + name: "阅读.apk", + type: "file", + size: 20485008, + }, + ] + : [] + } else { + // 目录项完全没有 id 字段:必须显式报错而不是静默列出根目录 + items = + parent === "/" + ? [ + { + name: "手机软件", + type: "folder", + updatedAt: "2026-10-04T19:47:40.056+08:00", + }, + ] + : [] + } + return new Response( + JSON.stringify({ success: true, code: "0000", data: { items } }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const driver = new Yun139Driver(addition) + ;(driver as any).client.personalHost = + "https://personal-kd-njs.yun.139.com/hcy" + + // 并集回退后能用 catalogId 解析子目录并列出真实内容 + const sub = await driver.list("/手机软件", "/手机软件") + assert.deepEqual( + sub.map((i) => i.name), + ["阅读.apk"], + ) + + // 任何 id 都拿不到时抛出明确错误,而不是 parentFileId="/" 静默列根目录 + mode = "noid" + await assert.rejects( + () => driver.list("/手机软件", "/手机软件"), + /has no catalog id/, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 init fails loudly when personal route is unavailable (issue #112)", async () => { + const authorization = Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64") + const originalFetch = globalThis.fetch + let routeMode: "error" | "empty" | "ok" = "error" + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + if (url.includes("qryRoutePolicy")) { + if (routeMode === "error") { + return new Response("boom", { status: 500 }) + } + if (routeMode === "empty") { + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { routePolicyList: [] }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { + routePolicyList: [ + { + modName: "personal", + httpsUrl: "https://personal-kd-njs.yun.139.com/hcy", + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + // personal_new + 路由查询失败 → 显式初始化失败(携带原始错误) + const d1 = new Yun139Driver({ authorization, type: "personal_new" }) + await assert.rejects( + () => d1.init(), + /failed to resolve personal cloud host.*boom/, + ) + + // personal_new + 路由返回空 personal → 同样失败,禁止回退默认域 + routeMode = "empty" + const d2 = new Yun139Driver({ authorization, type: "personal_new" }) + await assert.rejects( + () => d2.init(), + /failed to resolve personal cloud host/, + ) + + // 旧版个人云走 /orchestration 硬编码路径,不依赖路由 → 仅告警不失败 + routeMode = "error" + const d3 = new Yun139Driver({ authorization, type: "personal" }) + await d3.init() + + // 路由正常 → 初始化成功且 host 生效 + routeMode = "ok" + const d4 = new Yun139Driver({ authorization, type: "personal_new" }) + await d4.init() + assert.equal( + (d4 as any).client.personalHost, + "https://personal-kd-njs.yun.139.com/hcy", + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 request URL candidates and route-not-found retry semantics", async () => { + const authorization = Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64") + const originalFetch = globalThis.fetch + + try { + const client = new Yun139ApiClient({ authorization, type: "personal_new" }) + + // personal_new 禁止使用默认域(legacy /file/list 会忽略 parentFileId) + assert.throws( + () => (client as any).buildRequestUrls("/file/list"), + /routed personal host unavailable/, + ) + + // /hcy host:首选直拼,回退 orchestration 形态 + client.personalHost = "https://personal-kd-njs.yun.139.com/hcy" + assert.deepEqual((client as any).buildRequestUrls("/file/list"), [ + "https://personal-kd-njs.yun.139.com/hcy/file/list", + "https://personal-kd-njs.yun.139.com/orchestration/file/list", + ]) + + // 裸 host:补 /orchestration 前缀 + client.personalHost = "https://example.com" + assert.deepEqual((client as any).buildRequestUrls("/file/list"), [ + "https://example.com/orchestration/file/list", + ]) + + // 已带 /orchestration:单候选,不重复拼接 + client.personalHost = "https://x.example.com/orchestration" + assert.deepEqual((client as any).buildRequestUrls("/file/list"), [ + "https://x.example.com/orchestration/file/list", + ]) + + // 404 / route not found → 回退第二候选;其余错误原样抛出且不重试 + client.personalHost = "https://personal-kd-njs.yun.139.com/hcy" + const urls: string[] = [] + let primaryStatus = 404 + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input) + urls.push(url) + if (url.includes("/hcy/file/list")) { + if (primaryStatus === 404) { + return new Response(JSON.stringify({ message: "route not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }) + } + return new Response("internal error", { status: 500 }) + } + return new Response( + JSON.stringify({ success: true, code: "0000", data: { items: [] } }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + await client.request("/file/list", { parentFileId: "/" }) + assert.deepEqual(urls, [ + "https://personal-kd-njs.yun.139.com/hcy/file/list", + "https://personal-kd-njs.yun.139.com/orchestration/file/list", + ]) + + // 非路由类错误(500)不触发回退,错误信息带上请求 URL + urls.length = 0 + primaryStatus = 500 + await assert.rejects( + () => client.request("/file/list", { parentFileId: "/" }), + /139 Cloud API error \(500\) \[https:\/\/personal-kd-njs\.yun\.139\.com\/hcy\/file\/list\]/, + ) + assert.equal(urls.length, 1) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index 78f7e33c..cf38306f 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -35,6 +35,17 @@ export class Yun139Driver implements StorageDriver { return this.client.isPersonalNew() ? "/" : "" } + /** + * 目录名比较:去首尾空白并按 NFC 归一化。 + * 不同客户端/系统对同一中文目录名可能使用不同 Unicode 归一化形态, + * 严格 === 会把同名目录误判为找不到。 + */ + private nameEquals(a: string | undefined, b: string | undefined): boolean { + const na = (a || "").trim().normalize("NFC") + const nb = (b || "").trim().normalize("NFC") + return na === nb + } + private async resolveCatalogId(physicalPath: string): Promise { const clean = this.cleanPath(physicalPath) if (clean === "/") { @@ -46,14 +57,24 @@ export class Yun139Driver implements StorageDriver { for (const part of parts) { const disk = await this.client.listFiles(currentCatalogId) - const foundFolder = disk.folders.find((f) => f.catalogName === part) - if (foundFolder) { - currentCatalogId = foundFolder.catalogID - } else { + const foundFolder = disk.folders.find((f) => + this.nameEquals(f.catalogName, part), + ) + if (!foundFolder) { throw new Error( `[139] folder not found: "${part}" in path "${physicalPath}"`, ) } + // 目录项缺 id 时(已在 util.ts 的字段映射处做了 fileId/catalogId/ + // catalogID 并集回退,仍可能全部为空),绝不能把空字符串继续往下传: + // listFiles("") 会回退 parentFileId="/",静默列出根目录内容——按名 + // 解析明明成功,却悄悄让后续请求改查了根目录(issue #112 症状1)。 + if (!foundFolder.catalogID) { + throw new Error( + `[139] folder "${part}" has no catalog id in listing (path "${physicalPath}")`, + ) + } + currentCatalogId = foundFolder.catalogID } return currentCatalogId @@ -120,7 +141,9 @@ export class Yun139Driver implements StorageDriver { const parentCatalogId = await this.resolveCatalogId(parentPath) const disk = await this.client.listFiles(parentCatalogId) - const foundFolder = disk.folders.find((f) => f.catalogName === name) + const foundFolder = disk.folders.find((f) => + this.nameEquals(f.catalogName, name), + ) if (foundFolder) { return { name: foundFolder.catalogName, @@ -133,7 +156,9 @@ export class Yun139Driver implements StorageDriver { } } - const foundFile = disk.files.find((f) => f.contentName === name) + const foundFile = disk.files.find((f) => + this.nameEquals(f.contentName, name), + ) if (foundFile) { const sizeNum = typeof foundFile.contentSize === "number" @@ -218,11 +243,13 @@ export class Yun139Driver implements StorageDriver { const catalogId = await this.resolveCatalogId(dirPath) const disk = await this.client.listFiles(catalogId) - const folder = disk.folders.find((f) => f.catalogName === name) + const folder = disk.folders.find((f) => + this.nameEquals(f.catalogName, name), + ) if (folder) { await this.client.deleteCatalog(folder.catalogID) } else { - const file = disk.files.find((f) => f.contentName === name) + const file = disk.files.find((f) => this.nameEquals(f.contentName, name)) if (file && file.contentID) { await this.client.deleteFile(file.contentID) } diff --git a/src/backend/drivers/139/types.ts b/src/backend/drivers/139/types.ts index 4a98abe3..dceeba76 100644 --- a/src/backend/drivers/139/types.ts +++ b/src/backend/drivers/139/types.ts @@ -106,7 +106,14 @@ export interface PersonalThumbnail { } export interface PersonalFileItem { - fileId: string + // 部分账号 / 部分响应形态下,/file/list 的条目不携带 fileId,而是以 + // catalogId / catalogID 携带 id(经与社区实现 gaozhangmin/boxplayer 的 + // mapCloud139FileToAliModel 比对确认:139 新版个人云接口字段并不统一)。 + // 只读 fileId 会在这些条目上拿到 undefined,继而 listFiles(undefined) 回退 + // parentFileId="/" 静默列出根目录——这正是 issue #112 的症状1。 + fileId?: string + catalogId?: string + catalogID?: string name: string size?: number | string type: "folder" | "file" | string diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index b86b570c..edaa8ad2 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -50,11 +50,19 @@ export function formatTime(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } +// personal_new(新版个人云)的 /file/list 等端点只存在于 queryRoutePolicy +// 下发的路由 host(如 xxx/hcy)上;向这个默认域发请求会命中旧版 legacy 端点, +// 该端点会**静默忽略 parentFileId、恒返回根目录内容**(HTTP 200 + success:true, +// 不会抛错,因此此前任何“校验 success/code 再决定是否报错”的防御都拦不住)。 +// 对齐 AlistGo/alist 官方 Go 驱动 ensurePersonalCloudHost 的做法:拿不到路由 +// host 就是硬失败,绝不允许静默回退到这个占位域名。 +const YUN139_DEFAULT_HOST = "https://yun.139.com" + export class Yun139ApiClient { private addition: Yun139Addition - public personalHost = "https://yun.139.com" - public familyHost = "https://yun.139.com" - public groupHost = "https://yun.139.com" + public personalHost = YUN139_DEFAULT_HOST + public familyHost = YUN139_DEFAULT_HOST + public groupHost = YUN139_DEFAULT_HOST public account = "" private onAuthorizationRefresh?: (authorization: string) => Promise @@ -119,15 +127,9 @@ export class Yun139ApiClient { const bodyStr = JSON.stringify(body || {}) const sign = calSign(bodyStr, ts, randStr) - let url: string - if (uriOrUrl.startsWith("http://") || uriOrUrl.startsWith("https://")) { - url = uriOrUrl - } else if (uriOrUrl.startsWith("/orchestration/")) { - // Orchestration APIs are strictly hosted on yun.139.com - url = `https://yun.139.com${uriOrUrl}` - } else { - url = `${this.getHost()}${uriOrUrl}` - } + // 候选 URL 顺序即优先级;仅在"路由不存在"时才回退下一候选 + // (见 buildRequestUrls 的具体判断依据) + const urls = this.buildRequestUrls(uriOrUrl) const svcType = this.isFamily() ? "2" : "1" const headers: Record = { @@ -161,24 +163,115 @@ export class Yun139ApiClient { "X-Yun-Svc-Type": "1", } - const res = await fetch(url, { - method: "POST", - headers, - body: bodyStr, - }) - - if (!res.ok) { - const text = await res.text() - throw new Error(`139 Cloud API error (${res.status}): ${text}`) + let res: Response | undefined + let lastStatus = 0 + let lastBody = "" + let lastUrl = urls[0] || uriOrUrl + for (let i = 0; i < urls.length; i++) { + const candidate = urls[i] + lastUrl = candidate + const r = await fetch(candidate, { + method: "POST", + headers, + body: bodyStr, + }) + if (r.ok) { + res = r + break + } + lastStatus = r.status + lastBody = await r.text() + // 只有"路由不存在"才尝试下一候选;校验/权限/签名错误必须原样抛出, + // 不能被后续候选的错误掩盖(对齐社区实现 gaozhangmin/boxplayer 的 + // cloud139Request 重试语义:仅 404 / "route not found" 才回退)。 + const routeMissing = r.status === 404 || /route not found/i.test(lastBody) + if (!routeMissing || i === urls.length - 1) { + throw new Error( + `139 Cloud API error (${r.status}) [${candidate}]: ${lastBody}`, + ) + } + } + if (!res) { + throw new Error( + `139 Cloud API error (${lastStatus}) [${lastUrl}]: ${lastBody}`, + ) } - const json = (await res.json()) as any + let json: any + try { + json = (await res.json()) as any + } catch { + // 非 JSON(如被重定向到网页)时给出请求 URL,方便定位打错的 host + throw new Error(`139 Cloud returned a non-JSON response [${lastUrl}]`) + } if (json.success === false && json.message) { throw new Error(`139 Cloud API error: ${json.message}`) } return json as T } + /** + * 计算请求候选 URL(顺序即优先级),对齐 AlistGo/alist 官方 Go 驱动与 + * 社区实现 gaozhangmin/boxplayer 的 cloud139PersonalUrls: + * - 完整 URL 与 /orchestration 路径:单一候选,保持既有行为; + * - 走路由 host 的业务端点:首选 host 直拼(现网常见形态,如 + * xxx/hcy/file/list);404 / route not found 时回退 orchestration + * 形态——路由下发的 host 可能是 xxx/hcy,也可能只暴露 + * /orchestration,裸 host 则需要补 /orchestration 前缀。 + * - personal_new 禁止落到默认域:默认域的 /file/list 是旧版 legacy 端点, + * 会忽略 parentFileId 恒返回根目录内容(issue #112);init() 已经 + * 硬失败,这里是防御性兜底(例如长生命周期 isolate 中残留的陈旧实例)。 + */ + private buildRequestUrls(uriOrUrl: string): string[] { + if (uriOrUrl.startsWith("http://") || uriOrUrl.startsWith("https://")) { + return [uriOrUrl] + } + if (uriOrUrl.startsWith("/orchestration/")) { + // Orchestration APIs are strictly hosted on yun.139.com + return [`https://yun.139.com${uriOrUrl}`] + } + + const host = this.getHost() + if (this.isPersonalNew() && (!host || host === YUN139_DEFAULT_HOST)) { + throw new Error( + `[139] routed personal host unavailable, refusing to call ${uriOrUrl} on the default host`, + ) + } + + const hasPath = (value: string): boolean => { + try { + const p = new URL(value).pathname.replace(/\/+$/, "") + return Boolean(p) && p !== "/" + } catch { + return false + } + } + const toUrl = (base: string): string => { + const b = (base || "").replace(/\/+$/, "") + if (b.endsWith("/orchestration") || b.includes("/orchestration/")) { + return `${b}${uriOrUrl}` + } + // 带非空路径段的 host(如 .../hcy)直接拼接;裸 host 才补 + // /orchestration 前缀(新版个人云应用 boxplayer 的既有规则)。 + if (hasPath(b)) { + return `${b}${uriOrUrl}` + } + return `${b}/orchestration${uriOrUrl}` + } + + const primary = toUrl(host) + const urls = [primary] + const withoutHcy = (host || "") + .replace(/\/+$/, "") + .replace(/\/hcy\/orchestration\/?$/i, "/orchestration") + .replace(/\/hcy\/?$/i, "") + const alt = toUrl(withoutHcy) + if (alt !== primary) { + urls.push(alt) + } + return urls + } + /** * Refresh before the token enters its final 15 days. Called during init and * before each API request because Worker driver instances may stay cached. @@ -259,7 +352,9 @@ export class Yun139ApiClient { } const newExpiresAt = Number(newToken.split("|")[3]) if (!Number.isFinite(newExpiresAt) || newExpiresAt <= Date.now()) { - throw new Error("139 Cloud PC refresh returned a token without a valid future expiry") + throw new Error( + "139 Cloud PC refresh returned a token without a valid future expiry", + ) } this.addition.authorization = CryptoJS.enc.Base64.stringify( @@ -275,6 +370,7 @@ export class Yun139ApiClient { await this.refreshAuthorizationIfNeeded() + let routeError: unknown = null try { const routeRes = await this.request( "https://user-njs.yun.139.com/user/route/qryRoutePolicy", @@ -300,9 +396,36 @@ export class Yun139ApiClient { } } } catch (e) { + routeError = e + } + + // 新版个人云必须拿到路由下发的 personal host 才能工作:静默回退默认域 + // 会让 /file/list 命中忽略 parentFileId 的旧版端点——按名解析目录仍然 + // "成功"(因为该端点恒返回根目录内容,根目录下确有同名文件夹时查找不会 + // 落空),resolveCatalogId 的任何 throw 都不会被触发,表现为访问子目录 + // 却看到根目录的全部文件夹,点击文件又出现路径自我嵌套(issue #112)。 + // Go 官方驱动 ensurePersonalCloudHost 同样是硬失败;旧版个人云 + // (isPersonalNew()===false)走 /orchestration 硬编码路径,不依赖路由, + // 保留告警即可,不强制失败。 + if ( + this.isPersonalNew() && + (!this.personalHost || this.personalHost === YUN139_DEFAULT_HOST) + ) { + const detail = routeError + ? routeError instanceof Error + ? routeError.message + : String(routeError) + : "queryRoutePolicy returned no personal host" + throw new Error( + `[139] failed to resolve personal cloud host via queryRoutePolicy (${detail}); ` + + "refusing to fall back to the default host, which silently ignores " + + "parentFileId and always returns the root directory", + ) + } + if (routeError) { console.warn( "[139] queryRoutePolicy warning, fallback to default host:", - e, + routeError, ) } } @@ -334,13 +457,22 @@ export class Yun139ApiClient { const items = res.data?.items || [] allItems.push(...items) - nextPageCursor = res.data?.nextPageCursor || "" + // data.nextPageCursor 是 Go 官方驱动(drivers/139/util.go)确认的权威 + // 字段;额外防御游标与上一轮相同时终止,避免服务端重复回显同一游标 + // 导致死循环。 + const nextCursor = res.data?.nextPageCursor || "" + if (nextCursor === nextPageCursor) break + nextPageCursor = nextCursor } while (nextPageCursor) const folders = allItems .filter((i) => i.type === "folder") .map((i) => ({ - catalogID: i.fileId, + // 与社区实现 gaozhangmin/boxplayer 的 mapCloud139FileToAliModel + // 字段映射比对一致:部分条目以 catalogId/catalogID 携带 id 而非 + // fileId。只读 fileId 会拿到 undefined,随后 listFiles(undefined) + // 回退 parentFileId="/" 静默列出根目录(issue #112 症状1)。 + catalogID: i.fileId || i.catalogId || i.catalogID || "", catalogName: i.name, updateTime: i.updatedAt, })) @@ -348,7 +480,7 @@ export class Yun139ApiClient { const files: Yun139FileItem[] = allItems .filter((i) => i.type !== "folder") .map((i) => ({ - contentID: i.fileId, + contentID: i.fileId || i.catalogId || i.catalogID || "", contentName: i.name, contentSize: i.size, updateTime: i.updatedAt, From a3855e3f5418ccdf4530e5c35d4070f954ed2dc6 Mon Sep 17 00:00:00 2001 From: pikachuren Date: Fri, 16 Oct 2026 17:12:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(139):=20=E5=86=85=E5=AE=B9=E7=BA=A7?= =?UTF-8?q?=E8=87=AA=E6=A3=80=20+=20=E5=80=99=E9=80=89=20URL=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=20#112=20=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=BF=94=E5=9B=9E=E7=88=B6=E7=BA=A7=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:部署后子目录(如 /139/手机备份)仍返回根目录内容,且不报任何错。 原因:某些路由 host 形态会命中"忽略 parentFileId、恒返回父级/根目录内容"的 端点,并且返回 HTTP 200 + success:true。此前只按 404 / "route not found" 回退候选 URL,这种"静默错内容"永远发现不了,也不会触发回退。 本次改动: - 新增内容级自检 detectWrongListing():唯一判据是"一个节点不可能出现在它 自己的子项里"。请求文件夹 F 的列表里出现 F 自己(/手机备份 的列表里出现 手机备份、/手机软件 的列表里出现 手机软件)即为服务端忽略 parentFileId 的铁证,此时将该候选视为失败; - request() 支持 validate 回调:候选响应过不了自检就换下一个候选 URL, 全部候选都不过才抛错,错误信息列出所有试过的 URL(会写入存储状态—— EdgeOne 上看不到函数日志也能据此定位); - 候选 URL 扩展为多个等价形态(host 直拼 / host+/orchestration / host+/hcy+/orchestration / 去掉 /hcy 的两种形态),顺序保持"现网已工作 形态优先",健康账号仍只发一次请求;另按 boxplayer 语义把路由类业务码 9000/9008/9100 也视为可回退。 回归测试:新增 3 组(自检命中后自动换候选并返回正确内容、全部候选都返回 错内容时报错并列出 URL、健康账号只产生一次请求),12/12 通过;tsc 无错误。 --- src/backend/drivers/139/driver.test.ts | 164 +++++++++++++- src/backend/drivers/139/util.ts | 283 ++++++++++++++++++------- 2 files changed, 363 insertions(+), 84 deletions(-) diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index e80ea80f..15f13672 100644 --- a/src/backend/drivers/139/driver.test.ts +++ b/src/backend/drivers/139/driver.test.ts @@ -483,17 +483,21 @@ test("Yun139 request URL candidates and route-not-found retry semantics", async /routed personal host unavailable/, ) - // /hcy host:首选直拼,回退 orchestration 形态 + // /hcy host:顺序 = 现网已工作的直拼形态 → /hcy+/orchestration → + // 去掉 /hcy 的 orchestration 形态 → 去掉 /hcy 的直拼形态 client.personalHost = "https://personal-kd-njs.yun.139.com/hcy" assert.deepEqual((client as any).buildRequestUrls("/file/list"), [ "https://personal-kd-njs.yun.139.com/hcy/file/list", + "https://personal-kd-njs.yun.139.com/hcy/orchestration/file/list", "https://personal-kd-njs.yun.139.com/orchestration/file/list", + "https://personal-kd-njs.yun.139.com/file/list", ]) - // 裸 host:补 /orchestration 前缀 + // 裸 host:先补 /orchestration 前缀(社区实现规则),再尝试直拼 client.personalHost = "https://example.com" assert.deepEqual((client as any).buildRequestUrls("/file/list"), [ "https://example.com/orchestration/file/list", + "https://example.com/file/list", ]) // 已带 /orchestration:单候选,不重复拼接 @@ -527,7 +531,7 @@ test("Yun139 request URL candidates and route-not-found retry semantics", async await client.request("/file/list", { parentFileId: "/" }) assert.deepEqual(urls, [ "https://personal-kd-njs.yun.139.com/hcy/file/list", - "https://personal-kd-njs.yun.139.com/orchestration/file/list", + "https://personal-kd-njs.yun.139.com/hcy/orchestration/file/list", ]) // 非路由类错误(500)不触发回退,错误信息带上请求 URL @@ -542,3 +546,157 @@ test("Yun139 request URL candidates and route-not-found retry semantics", async globalThis.fetch = originalFetch } }) + +test("Yun139 retries the next candidate URL when the server ignores parentFileId (#112)", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + const rootItems = [ + { + fileId: "d_backup", + name: "手机备份", + type: "folder", + updatedAt: "2026-10-15T00:00:00.000+08:00", + }, + ] + const childItems = [ + { fileId: "f_1", name: "photo.jpg", type: "file", size: 10 }, + ] + + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input) + calls.push(url) + const body = JSON.parse(String(init?.body)) + const parent = String(body.parentFileId || "") + // 只有 host 直拼形态(/hcy/file/list)会忽略 parentFileId 恒返回根内容 + const ignoresParent = url.endsWith("/hcy/file/list") + const items = + ignoresParent && parent !== "/" + ? rootItems + : parent === "/" + ? rootItems + : childItems + return new Response( + JSON.stringify({ success: true, code: "0000", data: { items } }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const driver = new Yun139Driver(addition) + ;(driver as any).client.personalHost = + "https://personal-kd-njs.yun.139.com/hcy" + + const items = await driver.list("/手机备份", "/手机备份") + assert.deepEqual( + items.map((i) => i.name), + ["photo.jpg"], + ) + // 先打了会忽略 parentFileId 的候选,再自动换到下一个候选 + assert.ok( + calls.some((u) => u.endsWith("/hcy/file/list")), + calls.join(", "), + ) + assert.ok( + calls.some((u) => u.endsWith("/hcy/orchestration/file/list")), + calls.join(", "), + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 fails loudly with every candidate URL when all of them ignore parentFileId (#112)", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const originalFetch = globalThis.fetch + const rootItems = [{ fileId: "d_backup", name: "手机备份", type: "folder" }] + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { items: rootItems }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch + + try { + const driver = new Yun139Driver(addition) + ;(driver as any).client.personalHost = + "https://personal-kd-njs.yun.139.com/hcy" + + await assert.rejects( + () => driver.list("/手机备份", "/手机备份"), + (err: any) => { + const msg = String(err?.message || err) + assert.match(msg, /contains the requested folder itself/) + assert.match(msg, /tried: .*\/hcy\/file\/list/) + assert.match(msg, /\/hcy\/orchestration\/file\/list/) + assert.match(msg, /\/orchestration\/file\/list/) + return true + }, + ) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("Yun139 keeps using only the first candidate URL when the listing is plausible", async () => { + const addition: Yun139Addition = { + authorization: Buffer.from( + "Basic:13800138000:token123|1|1|99999999999999", + ).toString("base64"), + type: "personal_new", + } + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input) + calls.push(url) + const body = JSON.parse(String(init?.body)) + const parent = String(body.parentFileId || "") + const items = + parent === "/" + ? [{ fileId: "d_backup", name: "手机备份", type: "folder" }] + : [{ fileId: "f_1", name: "photo.jpg", type: "file", size: 10 }] + return new Response( + JSON.stringify({ success: true, code: "0000", data: { items } }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const driver = new Yun139Driver(addition) + ;(driver as any).client.personalHost = + "https://personal-kd-njs.yun.139.com/hcy" + + const items = await driver.list("/手机备份", "/手机备份") + assert.deepEqual( + items.map((i) => i.name), + ["photo.jpg"], + ) + // 健康账号不应产生额外候选请求(避免回归成"每次都打好几个 URL") + assert.deepEqual(calls, [ + "https://personal-kd-njs.yun.139.com/hcy/file/list", + "https://personal-kd-njs.yun.139.com/hcy/file/list", + ]) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index edaa8ad2..edc6cca4 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -58,6 +58,51 @@ export function formatTime(d: Date): string { // host 就是硬失败,绝不允许静默回退到这个占位域名。 const YUN139_DEFAULT_HOST = "https://yun.139.com" +/** 139 目录 id 的"根"写法:这些父 id 不做内容归属校验(根列表出现什么都不奇怪)。 */ +const ROOT_PARENT_IDS = new Set(["/", "0", "root", "cloud139_root"]) + +/** 路由类业务错误码:遇到时应换下一个候选 URL 重试(对齐社区实现 boxplayer)。 */ +const ROUTE_RETRY_CODES = new Set(["9000", "9008", "9100"]) + +/** 该父 id 是否为"根"哨兵值。 */ +export function isRootParentId(id: string | undefined | null): boolean { + const v = String(id ?? "").trim() + return v === "" || ROOT_PARENT_IDS.has(v) +} + +/** + * 判断一次目录列表响应是否"列错了目录"(issue #112 的核心自检)。 + * + * 唯一判据,数学上无歧义:**一个节点不可能出现在它自己的子项里**。 + * 当服务端忽略了 parentFileId(旧版端点、错误的路由形态等)而返回了该目录的 + * 父级/根内容时,我们请求的那个目录本身必然以同一个 id 出现在返回结果里—— + * 这正是观测到的形态:`/手机软件` 的列表里又出现 `手机软件`、 + * `/139/手机备份` 的列表里出现 `手机备份` 自己。 + * + * 返回 null 表示无法判定为异常(含根目录列表:根列表出现任何内容都合法)。 + */ +export function detectWrongListing( + requestedParentId: string | undefined | null, + items: Array & Record> | undefined, +): string | null { + if (isRootParentId(requestedParentId)) return null + const wanted = String(requestedParentId) + for (const item of items || []) { + if (!item) continue + const id = String( + item.fileId || item.catalogId || item.catalogID || item.contentID || "", + ) + if (id && id === wanted) { + return ( + `the returned listing contains the requested folder itself ` + + `("${item.name ?? ""}", id=${wanted}); the server ignored parentFileId ` + + `and returned the parent directory instead` + ) + } + } + return null +} + export class Yun139ApiClient { private addition: Yun139Addition public personalHost = YUN139_DEFAULT_HOST @@ -117,7 +162,21 @@ export class Yun139ApiClient { return this.personalHost } - async request(uriOrUrl: string, body: any): Promise { + /** + * 发送请求,按候选 URL 逐个尝试。 + * + * 何时换下一个候选(其余错误一律原样抛出,避免被后续候选的错误掩盖): + * - HTTP 404 / "route not found":该形态下路由不存在; + * - options.validate 判定响应"明显不属于请求目标":这是 issue #112 的 + * 关键——错误端点会静默忽略 parentFileId 并返回父级内容(HTTP 200 + + * success:true),只按 404 回退是永远发现不了的; + * - 路由类业务码 9000/9008/9100(对齐社区实现 gaozhangmin/boxplayer)。 + */ + async request( + uriOrUrl: string, + body: any, + options: { validate?: (json: any) => string | null } = {}, + ): Promise { // Worker driver instances are cached; check at request time as well as init // so a long-lived isolate cannot miss the refresh window. await this.refreshAuthorizationIfNeeded() @@ -127,8 +186,7 @@ export class Yun139ApiClient { const bodyStr = JSON.stringify(body || {}) const sign = calSign(bodyStr, ts, randStr) - // 候选 URL 顺序即优先级;仅在"路由不存在"时才回退下一候选 - // (见 buildRequestUrls 的具体判断依据) + // 候选 URL 顺序即优先级;回退条件见上方注释 const urls = this.buildRequestUrls(uriOrUrl) const svcType = this.isFamily() ? "2" : "1" @@ -163,64 +221,106 @@ export class Yun139ApiClient { "X-Yun-Svc-Type": "1", } - let res: Response | undefined + const tried: string[] = [] let lastStatus = 0 - let lastBody = "" - let lastUrl = urls[0] || uriOrUrl + let lastReason = "" + for (let i = 0; i < urls.length; i++) { const candidate = urls[i] - lastUrl = candidate - const r = await fetch(candidate, { + const isLast = i === urls.length - 1 + tried.push(candidate) + + const res = await fetch(candidate, { method: "POST", headers, body: bodyStr, }) - if (r.ok) { - res = r - break + + if (!res.ok) { + const text = await res.text() + lastStatus = res.status + lastReason = `HTTP ${res.status}: ${text}` + // 只有"路由不存在"才尝试下一候选;校验/权限/签名错误必须原样抛出, + // 不能被后续候选的错误掩盖。 + const routeMissing = res.status === 404 || /route not found/i.test(text) + if (!routeMissing || isLast) { + throw new Error( + `139 Cloud API error (${res.status}) [${candidate}]: ${text}`, + ) + } + continue } - lastStatus = r.status - lastBody = await r.text() - // 只有"路由不存在"才尝试下一候选;校验/权限/签名错误必须原样抛出, - // 不能被后续候选的错误掩盖(对齐社区实现 gaozhangmin/boxplayer 的 - // cloud139Request 重试语义:仅 404 / "route not found" 才回退)。 - const routeMissing = r.status === 404 || /route not found/i.test(lastBody) - if (!routeMissing || i === urls.length - 1) { - throw new Error( - `139 Cloud API error (${r.status}) [${candidate}]: ${lastBody}`, - ) + + let json: any + try { + json = await res.json() + } catch { + // 非 JSON(如被重定向到网页):换下一个候选,全部失败时列出 URL + lastReason = "non-JSON response body" + if (isLast) { + throw new Error( + `139 Cloud returned a non-JSON response, tried: ${tried.join(", ")}`, + ) + } + continue } - } - if (!res) { - throw new Error( - `139 Cloud API error (${lastStatus}) [${lastUrl}]: ${lastBody}`, - ) - } - let json: any - try { - json = (await res.json()) as any - } catch { - // 非 JSON(如被重定向到网页)时给出请求 URL,方便定位打错的 host - throw new Error(`139 Cloud returned a non-JSON response [${lastUrl}]`) - } - if (json.success === false && json.message) { - throw new Error(`139 Cloud API error: ${json.message}`) + const code = String(json?.code ?? "") + if (json?.success === false && json?.message) { + // 业务层明确报错:非路由类错误原样抛出(换候选只会掩盖真实原因) + if (!ROUTE_RETRY_CODES.has(code) || isLast) { + throw new Error(`139 Cloud API error: ${json.message}`) + } + lastReason = `business error: ${json.message}` + continue + } + if (ROUTE_RETRY_CODES.has(code)) { + const detail = json?.message || json?.msg || `code=${code}` + lastReason = `routing error (code=${code}): ${detail}` + if (isLast) { + throw new Error( + `139 Cloud API error [${candidate}]: ${detail} (code=${code})`, + ) + } + continue + } + + // 内容级自检:响应"明显不属于请求目标"时换下一个候选(issue #112) + const rejected = options.validate?.(json) || null + if (rejected) { + lastReason = rejected + if (isLast) { + throw new Error( + `139 Cloud API ignored parentFileId on every candidate URL: ${rejected}; tried: ${tried.join(", ")}`, + ) + } + continue + } + + return json as T } - return json as T + + throw new Error( + `139 Cloud API exhausted all candidate URLs (last status ${lastStatus}); ` + + `tried: ${tried.join(", ")}; last reason: ${lastReason || "unknown"}`, + ) } /** - * 计算请求候选 URL(顺序即优先级),对齐 AlistGo/alist 官方 Go 驱动与 - * 社区实现 gaozhangmin/boxplayer 的 cloud139PersonalUrls: - * - 完整 URL 与 /orchestration 路径:单一候选,保持既有行为; - * - 走路由 host 的业务端点:首选 host 直拼(现网常见形态,如 - * xxx/hcy/file/list);404 / route not found 时回退 orchestration - * 形态——路由下发的 host 可能是 xxx/hcy,也可能只暴露 - * /orchestration,裸 host 则需要补 /orchestration 前缀。 - * - personal_new 禁止落到默认域:默认域的 /file/list 是旧版 legacy 端点, - * 会忽略 parentFileId 恒返回根目录内容(issue #112);init() 已经 - * 硬失败,这里是防御性兜底(例如长生命周期 isolate 中残留的陈旧实例)。 + * 计算请求候选 URL(顺序即优先级)。对齐 AlistGo/alist 官方 Go 驱动与社区 + * 实现 gaozhangmin/boxplayer:路由下发的个人云 host 存在多个等价形态 + * (裸 host / host+/hcy / host+/orchestration / host+/hcy+/orchestration), + * 不同账号下发的形态不同,且错误的形态会**静默返回父级/根目录内容**而不是 + * 报错,因此必须准备多个候选并允许"内容级"回退(见 detectWrongListing)。 + * + * 顺序原则:把"现网已工作的形态"排在前面,健康账号只发一次请求。 + * - host 已带 /orchestration:直拼,单一候选; + * - 带其它路径段(如 .../hcy):先直拼,再补 /orchestration; + * - 裸 host:先补 /orchestration(boxplayer 规则),再尝试直拼。 + * 另外把路由 host 去掉 /hcy 的形态作为后备(同 boxplayer 的 withoutHcy)。 + * + * personal_new 禁止落到默认域:默认域的 /file/list 会忽略 parentFileId + * 恒返回根目录内容(issue #112);init() 已硬失败,这里是防御性兜底。 */ private buildRequestUrls(uriOrUrl: string): string[] { if (uriOrUrl.startsWith("http://") || uriOrUrl.startsWith("https://")) { @@ -238,36 +338,47 @@ export class Yun139ApiClient { ) } - const hasPath = (value: string): boolean => { + const bases: string[] = [] + const pushBase = (value: string): void => { + const b = (value || "").replace(/\/+$/, "") + if (b && !bases.includes(b)) bases.push(b) + } + pushBase(host) + // /hcy 与非 /hcy 两种形态互备(路由可能下发任一形态) + pushBase( + (host || "") + .replace(/\/+$/, "") + .replace(/\/hcy\/orchestration\/?$/i, "/orchestration") + .replace(/\/hcy\/?$/i, ""), + ) + + const urls: string[] = [] + const pushUrl = (value: string): void => { + if (value && !urls.includes(value)) urls.push(value) + } + const pathOf = (value: string): string => { try { - const p = new URL(value).pathname.replace(/\/+$/, "") - return Boolean(p) && p !== "/" + return new URL(value).pathname.replace(/\/+$/, "") } catch { - return false + return "" } } - const toUrl = (base: string): string => { - const b = (base || "").replace(/\/+$/, "") - if (b.endsWith("/orchestration") || b.includes("/orchestration/")) { - return `${b}${uriOrUrl}` - } - // 带非空路径段的 host(如 .../hcy)直接拼接;裸 host 才补 - // /orchestration 前缀(新版个人云应用 boxplayer 的既有规则)。 - if (hasPath(b)) { - return `${b}${uriOrUrl}` - } - return `${b}/orchestration${uriOrUrl}` - } - const primary = toUrl(host) - const urls = [primary] - const withoutHcy = (host || "") - .replace(/\/+$/, "") - .replace(/\/hcy\/orchestration\/?$/i, "/orchestration") - .replace(/\/hcy\/?$/i, "") - const alt = toUrl(withoutHcy) - if (alt !== primary) { - urls.push(alt) + for (const base of bases) { + const basePath = pathOf(base) + if (basePath.endsWith("/orchestration")) { + // host 本身已带 /orchestration:直拼即目标形态 + pushUrl(`${base}${uriOrUrl}`) + } else if (basePath && basePath !== "/") { + // 带路径段的 host(如 .../hcy):先按现网已工作的直拼形态,再补 + // /orchestration(新版接口也可能挂在 /hcy/orchestration 下) + pushUrl(`${base}${uriOrUrl}`) + pushUrl(`${base}/orchestration${uriOrUrl}`) + } else { + // 裸 host:先按社区实现补 /orchestration,再尝试直拼 + pushUrl(`${base}/orchestration${uriOrUrl}`) + pushUrl(`${base}${uriOrUrl}`) + } } return urls } @@ -444,16 +555,26 @@ export class Yun139ApiClient { const parentFileId = folderId || this.addition.root_folder_id || "/" do { - const res = await this.request("/file/list", { - parentFileId, - pageInfo: { - pageCursor: nextPageCursor, - pageSize: 100, + const res = await this.request( + "/file/list", + { + parentFileId, + pageInfo: { + pageCursor: nextPageCursor, + pageSize: 100, + }, + orderBy: "updated_at", + orderDirection: "DESC", + imageThumbnailStyleList: ["Small", "Large"], + }, + { + // 内容级自检:响应里出现"被请求的目录自己"就是服务端忽略了 + // parentFileId 的铁证(见 detectWrongListing)——此时换下一个 + // 候选 URL 再试;全部候选都如此则抛错并列出所有试过的 URL。 + validate: (json: any) => + detectWrongListing(parentFileId, json?.data?.items), }, - orderBy: "updated_at", - orderDirection: "DESC", - imageThumbnailStyleList: ["Small", "Large"], - }) + ) const items = res.data?.items || [] allItems.push(...items)