diff --git a/README.md b/README.md index 7731821..5e413ec 100644 --- a/README.md +++ b/README.md @@ -788,10 +788,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 **Q:端口 3200 被占用?** -A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 +A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。 + +**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)** +A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。 **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** -A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: +A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 修好版本后重新 `npm install && npm run build` 并重启即可。 diff --git a/src/music/api-server.test.ts b/src/music/api-server.test.ts index c0309ce..6b8067d 100644 --- a/src/music/api-server.test.ts +++ b/src/music/api-server.test.ts @@ -1,5 +1,34 @@ -import { describe, it, expect } from "vitest"; -import { describeQqApiStartupError } from "./api-server.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createApiServerManager, describeQqApiStartupError } from "./api-server.js"; +import type { Logger } from "../logger.js"; + +// Record every listen() the QQ sidecar makes so we can assert it is always +// pinned to the configured port (regression coverage for issue #122). +const mockState = vi.hoisted(() => ({ + listenCalls: [] as Array<{ port: number; host: string }>, +})); + +vi.mock("@sansenjian/qq-music-api", () => { + const app = { + listen(port: number, host: string, cb?: () => void) { + mockState.listenCalls.push({ port, host }); + const server = { + address: () => ({ port, address: host, family: "IPv4" as const }), + on() { + return server; + }, + close(done?: () => void) { + done?.(); + }, + }; + // Real net/Koa fire the listening callback on a later tick, after the + // caller has captured the returned server handle. + if (cb) setImmediate(cb); + return server; + }, + }; + return { default: app }; +}); describe("describeQqApiStartupError", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { @@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => { expect(describeQqApiStartupError(null)).toBeNull(); }); }); + +// Regression coverage for issue #122: the QQ Music API sidecar must listen on +// the same port the client base URL targets (config.qqMusicApiPort). A stale +// build once bound 3300 while the client requested 3200, silently breaking the +// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200. +describe("createApiServerManager — QQ sidecar port binding", () => { + const noopLogger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + } as unknown as Logger; + + beforeEach(() => { + mockState.listenCalls = []; + }); + + it("listens on the configured qqMusicPort and exposes a matching base URL", async () => { + const port = 39217; // uncommon port to avoid clashing with a real instance + const manager = createApiServerManager( + { neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + await manager.start(); + manager.stop(); + + expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`); + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + }); + + it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => { + const port = 39219; + const previous = process.env.PORT; + // Simulate a hosting platform / compose file injecting a stray PORT that + // must NOT leak into the QQ sidecar's chosen port. + process.env.PORT = "39999"; + const manager = createApiServerManager( + { neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // The sidecar follows qqMusicPort, never the injected PORT. + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + // The injected PORT is restored so nothing else in the process is affected. + expect(process.env.PORT).toBe("39999"); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); + + it("leaves an absent PORT env unset after importing the sidecar", async () => { + const port = 39221; + const previous = process.env.PORT; + delete process.env.PORT; + const manager = createApiServerManager( + { neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // Was unset before importing — must be unset again, no leaked override. + expect(process.env.PORT).toBeUndefined(); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); +}); diff --git a/src/music/api-server.ts b/src/music/api-server.ts index 08fe4b8..1374268 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -114,7 +114,25 @@ export function createApiServerManager( "QQ Music API port already in use — reusing existing instance" ); } else { - const qqModule = (await import("@sansenjian/qq-music-api")) as any; + // Pin the upstream server to the configured port before importing. + // The package derives its default port from process.env.PORT (falling + // back to 3200) and, in some historical versions, auto-started that + // server as an import side effect. Aligning PORT with qqMusicApiPort + // guarantees the sidecar can never bind a different port than the one + // the client base URL (getQQMusicBaseUrl) targets — the root cause of + // issue #122, where an old build listened on 3300 while the client + // requested 3200. Restore the previous value right after import so we + // never leak the override into the rest of the process (e.g. the web + // server or the NetEase sidecar, which also read PORT as a fallback). + const prevPortEnv = process.env.PORT; + process.env.PORT = String(options.qqMusicPort); + let qqModule: any; + try { + qqModule = (await import("@sansenjian/qq-music-api")) as any; + } finally { + if (prevPortEnv === undefined) delete process.env.PORT; + else process.env.PORT = prevPortEnv; + } // The module's export structure varies between versions: // 2.2.11+: default → Koa app (has .listen) // 2.2.10: default → wrapper object whose .default is the Koa app @@ -124,16 +142,34 @@ export function createApiServerManager( ? candidate : candidate.default ?? null; if (koaApp && typeof koaApp.listen === "function") { - qqMusicServer = await new Promise((resolve, reject) => { - const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => - resolve(srv) + // A version that auto-started on import has already bound the + // configured port (thanks to the PORT alignment above); reuse it + // rather than racing a second listen that would fail EADDRINUSE. + const stillFree = await isPortFree(options.qqMusicPort); + if (!stillFree) { + logger.info( + { port: options.qqMusicPort }, + "QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance" ); - srv.on("error", reject); - }); - logger.info( - { port: options.qqMusicPort }, - "QQ Music API started" - ); + } else { + qqMusicServer = await new Promise((resolve, reject) => { + const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => + resolve(srv) + ); + srv.on("error", reject); + }); + // Log the port actually bound (read from the socket) rather than + // the requested one, so operators can spot a mismatch in the logs. + const addr = qqMusicServer.address(); + const boundPort = + addr && typeof addr === "object" && addr !== null + ? addr.port + : options.qqMusicPort; + logger.info( + { port: boundPort }, + "QQ Music API started" + ); + } } else { logger.warn("QQ Music API module does not expose a Koa app"); }