From 97adb8b0fd5a871dc47a1551c68614785b7ef34f Mon Sep 17 00:00:00 2001 From: wumingzhinu <78893617+wumingzhinu@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:19:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(139):=20=E5=AD=90=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=9D=99=E9=BB=98=E5=9B=9E=E9=80=80=E6=A0=B9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8E=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=A5=97=E5=A8=83=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveCatalogId 逐段查找目录时,任何一段没找到就 break 并静默返回 最近一个已解析的父目录 id,list() 拿着这个 id 继续列目录: - 路径末段是文件(/手机软件/阅读.apk)时,list() 返回父目录内容, 前端把文件路径渲染成目录,出现 /手机软件/阅读.apk/阅读.apk 套娃; - 首段就解析失败(列表瞬时缺失、缓存不一致等)时回退到根目录, 表现为访问子目录却看到根目录的全部文件夹(issue #112 症状 1)。 修复: - resolveCatalogId 解析不到即抛 Path not found: ,不再静默 回退;平台返回明确错误,前端不再把文件/不存在路径当目录渲染。 - 目录/文件名比较改为 trim + Unicode NFC 归一化,避免同名目录因 归一化形态不同被误判为找不到。 - 个人云 /file/list 翻页游标优先读 data.pageInfo.nextPageCursor (社区实现 gaozhangmin/boxplayer 实测的响应形态),保留 data.nextPageCursor 兼容回退;游标不再变化时终止循环防死循环。 此前只读 data.nextPageCursor,超过 100 项的目录只返回第一页。 新增 3 组回归测试:子目录内容/文件路径报错/不存在目录报错、NFC 归一化匹配、pageInfo 翻页合并;139 驱动测试 6/6 通过。 Relates to #112 Co-Authored-By: MiMo (OpenCode) --- src/backend/drivers/139/driver.test.ts | 139 +++++++++++++++++++++++++ src/backend/drivers/139/driver.ts | 41 ++++++-- src/backend/drivers/139/types.ts | 5 + src/backend/drivers/139/util.ts | 10 +- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index 93c6abad..f74b415e 100644 --- a/src/backend/drivers/139/driver.test.ts +++ b/src/backend/drivers/139/driver.test.ts @@ -144,3 +144,142 @@ 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"), + /Path not found/, + ) + + // 不存在的目录必须显式报错,而不是静默回退到根目录 + await assert.rejects( + () => driver.list("/不存在的目录", "/不存在的目录"), + /Path 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.pageInfo pagination cursor", 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, + pageInfo: { pageCursor: cursor, pageSize: 100, nextPageCursor: next }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + }) as typeof fetch + + try { + const client = new Yun139ApiClient(addition) + 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 + } +}) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index 70241b42..369b4a83 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,12 +57,18 @@ 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 { - break + const foundFolder = disk.folders.find((f) => + this.nameEquals(f.catalogName, part), + ) + // 解析失败的层级必须显式抛出,不能静默回退到最近一个已解析的父目录: + // - 路径末段是文件时(如 /手机软件/阅读.apk),回退会让 list() 列出父目录 + // 内容,前端把文件路径渲染成目录,出现 /手机软件/阅读.apk/阅读.apk 套娃; + // - 首段就解析失败时(列表瞬时缺失/缓存不一致),回退会列出根目录内容, + // 表现为访问子目录却看到根目录的全部文件夹(issue #112)。 + if (!foundFolder) { + throw new Error(`Path not found: ${clean}`) } + currentCatalogId = foundFolder.catalogID } return currentCatalogId @@ -118,7 +135,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, @@ -131,7 +150,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" @@ -216,11 +237,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..f5d7463c 100644 --- a/src/backend/drivers/139/types.ts +++ b/src/backend/drivers/139/types.ts @@ -122,6 +122,11 @@ export interface PersonalListResp { data?: { items?: PersonalFileItem[] nextPageCursor?: string + pageInfo?: { + pageCursor?: string + pageSize?: number + nextPageCursor?: string + } } } diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index b86b570c..6b5f0476 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -334,7 +334,15 @@ export class Yun139ApiClient { const items = res.data?.items || [] allItems.push(...items) - nextPageCursor = res.data?.nextPageCursor || "" + // 新版个人云的翻页游标嵌在 data.pageInfo 里(社区实现 + // gaozhangmin/boxplayer 实测),旧形态 data.nextPageCursor 作兼容回退; + // 游标与上一轮相同时终止,防止服务端回显同一游标导致死循环。 + const nextCursor = + res.data?.pageInfo?.nextPageCursor || res.data?.nextPageCursor || "" + if (nextCursor === nextPageCursor) { + break + } + nextPageCursor = nextCursor } while (nextPageCursor) const folders = allItems From b48634c64d4136f3ac5366d0bed130e8d5401267 Mon Sep 17 00:00:00 2001 From: wumingzhinu <78893617+wumingzhinu@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:25:24 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(139):=20=E8=B7=AF=E7=94=B1=20host=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E4=B8=8E=E7=9B=AE=E5=BD=95=20id=20=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者反馈 #121(break→throw)并未解决症状 1:按名查找目录成功时 任何 throw 都不会触发,问题出在查找成功之后——解析结果或请求目标 host 静默退化为根目录: - init() 在 personal_new 取不到 queryRoutePolicy 下发的 personal host 时硬失败(对齐 Go 版 PersonalCloudHost is empty → Init error), 不再静默回退默认域 yun.139.com——默认域的 /file/list 是 legacy 端点,忽略 parentFileId 恒返回根目录内容,且因按名解析仍成功而不 触发任何报错(issue #112 症状 1、#121 无效的原因)。 - 新增 buildRequestUrls:personal_new 拒绝把业务端点发到默认域; 候选 URL 按 host 直拼(现网 /hcy 形态)优先,404/route not found 时回退 orchestration 形态(裸 host 补 /orchestration),仅路由类 错误重试——对齐 gaozhangmin/boxplayer 的候选序列与重试语义。 - /file/list 目录项 id 并集回退 fileId||catalogId||catalogID;仍拿 不到 id 时 resolveCatalogId 抛错,杜绝 listFiles(undefined) 回退 parentFileId="/" 的静默列根目录路径。 - 错误信息附带请求 URL,非 JSON 响应显式报错,便于复现者直接定位。 新增 3 组回归测试(id 并集与空 id 报错、init 硬失败、URL 候选与 重试语义);139 驱动测试 9/9 通过,tsc --strict、prettier 通过。 未做端到端真实 API 验证(无139 账号凭据)。 Relates to #112 Co-Authored-By: MiMo (OpenCode) --- src/backend/drivers/139/driver.test.ts | 271 ++++++++++++++++++++++++- src/backend/drivers/139/driver.ts | 9 + src/backend/drivers/139/types.ts | 7 +- src/backend/drivers/139/util.ts | 162 ++++++++++++--- 4 files changed, 413 insertions(+), 36 deletions(-) diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index f74b415e..84b675be 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 }) @@ -101,7 +104,14 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { 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 +127,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 +151,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"), @@ -267,6 +294,8 @@ test("Yun139 personal_new list follows data.pageInfo pagination cursor", async ( 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, "") @@ -283,3 +312,227 @@ test("Yun139 personal_new list follows data.pageInfo pagination cursor", async ( 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)——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(), /queryRoutePolicy failed.*boom/) + + // personal_new + 路由返回空 personal → 同样失败,禁止回退默认域 + routeMode = "empty" + const d2 = new Yun139Driver({ authorization, type: "personal_new" }) + await assert.rejects(() => d2.init(), /queryRoutePolicy failed/) + + // 旧版个人云走 /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 形态(boxplayer 实测序列) + 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 369b4a83..e3570339 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -68,6 +68,15 @@ export class Yun139Driver implements StorageDriver { if (!foundFolder) { throw new Error(`Path not found: ${clean}`) } + // 目录项缺 id 时(部分 /file/list 响应以 catalogId/catalogID 携带, + // 已在 util 映射处并集回退),仍拿不到 id 就必须报错:继续往下传会让 + // listFiles(undefined) 回退 parentFileId="/",静默列出根目录内容 + // (issue #112 的症状1,按名解析成功、任何“找不到目录”的报错都不会触发)。 + if (!foundFolder.catalogID) { + throw new Error( + `[139] folder "${part}" has no catalog id in listing: ${clean}`, + ) + } currentCatalogId = foundFolder.catalogID } diff --git a/src/backend/drivers/139/types.ts b/src/backend/drivers/139/types.ts index f5d7463c..b5133a41 100644 --- a/src/backend/drivers/139/types.ts +++ b/src/backend/drivers/139/types.ts @@ -106,7 +106,12 @@ export interface PersonalThumbnail { } export interface PersonalFileItem { - fileId: string + // 部分账号的 /file/list 响应里目录项没有 fileId,而是以 catalogId/catalogID + // 携带 id(社区实现 gaozhangmin/boxplayer 对四种键名做了并集回退); + // 缺 id 的目录项若只读 fileId,解析会拿到 undefined 并静默回退列根目录(#112)。 + 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 6b5f0476..4734dc49 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -50,11 +50,17 @@ 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())}` } +// 139 默认域仅作占位。新版个人云的 /file/list 等端点只存在于 queryRoutePolicy +// 下发的路由 host(如 xxx/hcy)上;向默认域发请求会命中 legacy 端点并忽略 +// parentFileId 恒返回根目录内容(issue #112),因此 personal_new 取不到路由 +// 时必须显式初始化失败(与 Go 版驱动一致),不允许静默回退。 +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 +125,8 @@ 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 +160,108 @@ 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() + // 只有“路由不存在”才尝试下一候选;校验/权限/签名错误必须原样抛出, + // 不能被后续 404 掩盖(对齐 gaozhangmin/boxplayer 的重试语义)。 + 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(顺序即优先级),对齐 Go 版与 gaozhangmin/boxplayer: + * - 完整 URL 与 /orchestration 路径:与 Go 版一致,单一候选; + * - 走路由 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/")) { + 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(/\/+$/, "") + // 仅新版个人云应用 boxplayer 的裸 host 补 /orchestration 规则; + // 其余类型保持 host 直拼的既有行为。 + if (this.isPersonalNew() && !hasPath(b)) { + return `${b}/orchestration${uriOrUrl}` + } + return `${b}${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 +342,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 +360,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 +386,30 @@ export class Yun139ApiClient { } } } catch (e) { + routeError = e + } + + // 新版个人云必须拿到路由下发的 personal host 才能工作:静默回退默认域会让 + // /file/list 命中 legacy 端点、忽略 parentFileId 恒返回根目录——按名解析 + // 目录仍然成功,#121/本 PR 的 throw 都不会触发,表现为访问子目录却看到 + // 根目录的全部文件夹(issue #112)。Go 版驱动此处同样是硬失败 + // (PersonalCloudHost is empty → Init error),旧版个人云走 /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] queryRoutePolicy failed: ${detail}`) + } + if (routeError) { console.warn( "[139] queryRoutePolicy warning, fallback to default host:", - e, + routeError, ) } } @@ -348,7 +455,10 @@ export class Yun139ApiClient { const folders = allItems .filter((i) => i.type === "folder") .map((i) => ({ - catalogID: i.fileId, + // 部分账号的目录项以 catalogId/catalogID 携带 id 而非 fileId; + // 只读 fileId 会拿到 undefined,随后 listFiles(undefined) 回退 + // parentFileId="/" 静默列出根目录(issue #112)。 + catalogID: i.fileId || i.catalogId || i.catalogID || "", catalogName: i.name, updateTime: i.updatedAt, })) @@ -356,7 +466,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,