diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index 93c6abad..15f13672 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,530 @@ 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:顺序 = 现网已工作的直拼形态 → /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 前缀(社区实现规则),再尝试直拼 + 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:单候选,不重复拼接 + 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/hcy/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 + } +}) + +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/driver.ts b/src/backend/drivers/139/driver.ts index 70241b42..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,12 +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 { - break + 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 @@ -118,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, @@ -131,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" @@ -216,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..edc6cca4 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -50,11 +50,64 @@ 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" + +/** 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 = "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 @@ -109,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() @@ -119,15 +186,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 顺序即优先级;回退条件见上方注释 + const urls = this.buildRequestUrls(uriOrUrl) const svcType = this.isFamily() ? "2" : "1" const headers: Record = { @@ -161,22 +221,166 @@ export class Yun139ApiClient { "X-Yun-Svc-Type": "1", } - const res = await fetch(url, { - method: "POST", - headers, - body: bodyStr, - }) + const tried: string[] = [] + let lastStatus = 0 + let lastReason = "" + + for (let i = 0; i < urls.length; i++) { + const candidate = urls[i] + const isLast = i === urls.length - 1 + tried.push(candidate) + + const res = await fetch(candidate, { + method: "POST", + headers, + body: bodyStr, + }) + + 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 + } + + 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 + } + + 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 + } + + 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:路由下发的个人云 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://")) { + return [uriOrUrl] + } + if (uriOrUrl.startsWith("/orchestration/")) { + // Orchestration APIs are strictly hosted on yun.139.com + return [`https://yun.139.com${uriOrUrl}`] + } - if (!res.ok) { - const text = await res.text() - throw new Error(`139 Cloud API error (${res.status}): ${text}`) + 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 json = (await res.json()) as any - if (json.success === false && json.message) { - throw new Error(`139 Cloud API error: ${json.message}`) + const bases: string[] = [] + const pushBase = (value: string): void => { + const b = (value || "").replace(/\/+$/, "") + if (b && !bases.includes(b)) bases.push(b) } - return json as T + 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 { + return new URL(value).pathname.replace(/\/+$/, "") + } catch { + return "" + } + } + + 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 } /** @@ -259,7 +463,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 +481,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 +507,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, ) } } @@ -321,26 +555,45 @@ 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) - 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 +601,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, 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",