Explorar o código

feat(nls): token 改由后端网关签发(用户指定 getALYToken),前端不再需要 AK/SK

用户要求:「获取临时token修改为调用 http://121.43.55.7:10011/proxy/verify/getALYToken,
对应的 appkey 为 3jRt1atwVuD9dh12」。

先探端点:GET 返回 {"code":200,"message":"成功","content":"<32 位 token>"}(每次新签一个)。
再**先用它 + 新 appkey 打了一次真实识别** → 结果正确,确认这对 token/appkey 可用,才动前端。

改动:
- vite.config.ts:新增 /aly-api/ 代理 → http://121.43.55.7:10011(与 /ip-api/ 同一台机器、
  同样的混合内容原因:端点只有 HTTP、页面是 HTTPS,必须走同源路径);**删掉**先前那段
  "dev 期自己签 token" 的 /nls-token/ 中间件 —— 前端侧不再需要 AccessKeySecret
- src/network/api/nls-token.ts:改调网关,解析 {code, content};网关不返回过期时间,
  按官方文档的 24 小时估;缓存/并发去重照旧
- runtime-config:getNlsTokenApiUrl()(默认 /aly-api/proxy/verify/getALYToken,可用
  VITE_NLS_TOKEN_API 覆盖)+ getNlsAppkey()(VITE_NLS_APPKEY)
- 配置:VITE_NLS_TOKEN_API 进 .env.development(同源路径,不是凭据);
  appkey 进 .env.development.local(gitignored,按"不记录具体值"的口径)
- harness:nls-token.mjs 新增 resolveNlsToken()(显式 token → 网关 → AK/SK 自签退路),
  探针与验证脚本改走它 ⇒ 现在的验证走的就是生产同一条路

验证:curl /aly-api/... → 200 + 网关原文(代理生效);旧 /nls-token/ 已停用;
verify-nls-realtime.mjs 42/42 且真实识别打印「token 来源:gateway」;dev server 已把
VITE_NLS_APPKEY 注入前端模块;npm run build、vue-tsc、validate-harness 通过。

🔴 部署必配(已登记技术债 #10 与 asr-nls.md):① nginx 把 /aly-api/ 转发到
http://121.43.55.7:10011/(去前缀)② .env.production / .env.qingpu / .env.test 都没配
VITE_NLS_APPKEY —— 实测生产构建里 appkey 为空,上线会因空 appkey 失败。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao hai 1 mes
pai
achega
d0f9847670

+ 8 - 0
.env.development

@@ -41,3 +41,11 @@ VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"
 # 它是 **HTTP**,而页面是 HTTPS —— 直连会被混合内容拦掉,所以走同源代理
 VITE_IP_API="/ip-api"
 VITE_IP_TARGET="http://121.43.55.7:10011"
+
+# 阿里云 NLS(实时语音识别)的 token 端点 —— 后端网关提供,**同源路径**。
+# 网关只有 HTTP、页面是 HTTPS,直连会被混合内容拦掉,所以走 vite 代理(见 vite.config.ts 的 /aly-api/)。
+# 生产要在 nginx 里把 /aly-api/ 转发到 http://121.43.55.7:10011/(去掉前缀),前端代码不用改。
+VITE_NLS_TOKEN_API="/aly-api/proxy/verify/getALYToken"
+
+# NLS 的 appkey(识别请求要带)。它**不是密钥**,但按约定不写进仓库文件:
+# 开发期放 .env.development.local(gitignored),生产由部署侧注入 VITE_NLS_APPKEY。

+ 17 - 1
harness/docs/exec-plans/tech-debt-tracker.md

@@ -131,7 +131,21 @@
   所以也可以**不改构建、只改部署产物里的这一行**——但同样绕不开 ②。
 - **当前状态**:仅登记,未处理。**需要用户决定生产怎么接。**
 
-### 10. 生产的 NLS token 端点还没人做(语音识别换阿里云 NLS)
+### 10.(2026-09-24 已大半还清)生产的 NLS token 端点
+
+> **进展**:用户 2026-09-24 给了**后端网关的 token 端点**
+> `GET http://121.43.55.7:10011/proxy/verify/getALYToken` → `{code:200, content:"<token>"}`,
+> 前端已改为调它(同源 `/aly-api/…`,dev 走 vite 代理)。**前端侧不再需要 AK/SK**,
+> vite 里那段自签中间件已删除。
+> **剩下的(部署侧,仍未做)**:
+> ① nginx 把 `/aly-api/` 转发到 `http://121.43.55.7:10011/`(去掉前缀,与 `/ip-api/` 同法);
+> ② **生产三份 env 都要配 `VITE_NLS_APPKEY`**(实测:`.env.production` / `.env.qingpu` / `.env.test`
+> 都没有 → 生产构建打出来的包里 appkey 为空 → 识别会失败)。
+> 详见 [`../reference/asr-nls.md`](../reference/asr-nls.md)。
+
+<details>
+<summary>原来的记录(2026-09-23)</summary>
+
 
 - **是什么**:语音识别要换成阿里云 NLS,而 NLS 的 token 必须用 **AccessKeySecret** 签名去换
   (`CreateToken`,24 小时过期)。secret **不能进前端产物**(bundle 是公开的),
@@ -146,6 +160,8 @@
 - **当前状态**:dev 已通(端点 200、token 能真调通识别、构建产物零凭据泄漏);
   生产未做,**需要用户/后端拍板由谁实现**。
 
+</details>
+
 ## 已还
 
 ### 访客行会被企业看到(2026-09-22 已还)

+ 22 - 10
harness/docs/reference/asr-nls.md

@@ -10,27 +10,39 @@
 旧的实时语音识别走厂商 metamaker 的中转(`/common/asr_hub?...&engine=aliyun_dashscope`,
 `asr-realtime.md` 有完整记录)。2026-09-23 用户要求**换成阿里云 NLS 直连**。
 
-前端这一侧的关键差别:**前端不再持有任何云凭据**。NLS 的 token 要用 AccessKeySecret
-签名换取,而 secret 不能进前端产物 —— 所以由服务端换好 token 再下发:
+前端这一侧的关键差别:**前端不持有任何云凭据**(签名要 AccessKeySecret,而 bundle 是公开的)。
+token 由**后端网关**签发(2026-09-24 起,用户指定):
 
 ```
-浏览器  ──GET /nls-token/──▶  服务端(dev:vite 中间件,见 harness/tools/nls-token.mjs)
-                                │  用 AK/SK 签 CreateToken
-                                ▼
-                            阿里云 nls-meta  ← 返回 token(24h)
-浏览器  ◀── { token, expireAt, appkey } ──┘
-浏览器  ──wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…──▶ NLS 网关
+浏览器 ──GET /aly-api/proxy/verify/getALYToken──▶ 同源代理
+        (dev:vite 代理 → http://121.43.55.7:10011;生产:nginx 转发同一路径)
+                                                  ▼
+                                    后端网关 ──(AK/SK 签名)──▶ 阿里云 nls-meta
+浏览器 ◀──── { code:200, message:"成功", content:"<32 位 token>" } ────┘
+浏览器 ──wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…──▶ NLS 网关
 ```
 
+> ⚠️ 端点**只有 HTTP**、页面是 HTTPS → **必须走同源路径**,直连会被混合内容拦掉
+> (与 `/ip-api/` 同一个坑、同一台机器)。生产要在 nginx 里把 `/aly-api/`
+> 转发到 `http://121.43.55.7:10011/`(去掉前缀)。
+> 网关**不返回过期时间**,前端按官方文档的 24 小时估(`TOKEN_LIFETIME_MS`)。
+
 | 件 | 在哪 |
 |---|---|
 | 前端取 token(带缓存) | [`src/network/api/nls-token.ts`](../../../src/network/api/nls-token.ts) |
 | 协议层(**纯函数**,可被 harness 测) | [`src/three-libs/asr/nls-protocol.ts`](../../../src/three-libs/asr/nls-protocol.ts) |
 | 采集与连接(**对外契约与旧 SDK 一致**) | [`src/three-libs/asr/nls.ts`](../../../src/three-libs/asr/nls.ts) |
-| 服务端换 token(dev) | `vite.config.ts` 的 `/nls-token/` + [`harness/tools/nls-token.mjs`](../../tools/nls-token.mjs) |
-| 验证脚本 | [`harness/tools/verify-nls-realtime.mjs`](../../tools/verify-nls-realtime.mjs)(30 项,含真实识别) |
+| 取 token 的三种来源(工具侧统一入口) | [`harness/tools/nls-token.mjs`](../../tools/nls-token.mjs):显式 `NLS_TOKEN` → **网关** → AK/SK 自签(退路) |
+| 验证脚本 | [`harness/tools/verify-nls-realtime.mjs`](../../tools/verify-nls-realtime.mjs)(42 项,含真实识别) |
 | 探针(读原始帧用) | [`harness/tools/probe-nls-realtime.mjs`](../../tools/probe-nls-realtime.mjs) |
 
+**配置项**(两个,都不含密钥):
+
+| 变量 | 值 | 说明 |
+|---|---|---|
+| `VITE_NLS_TOKEN_API` | `/aly-api/proxy/verify/getALYToken` | 同源路径;已在 `.env.development`,**生产三份 env 也要写** |
+| `VITE_NLS_APPKEY` | NLS 的 appkey | **不是密钥**(换 token 不需要它、网关也不校验它),但按约定不写进仓库:开发期在 `.env.development.local`(gitignored),**生产必须由部署侧注入,否则识别会因空 appkey 失败** |
+
 ## 2. 连接与指令
 
 ```

+ 35 - 0
harness/progress.md

@@ -4139,6 +4139,41 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
 **⚠️ 仍需用户复测**:刷新页面后再点一次麦克风,看是否"说出去几乎同时出字"。
 剩下的 ~150ms 是链路、~480ms 刷新节奏是引擎的固有节奏(见 asr-nls.md §4.5)。
 
+### 续 36:token 改由后端网关签发(用户指定端点)
+
+**用户要求**:「获取临时token修改为调用 `http://121.43.55.7:10011/proxy/verify/getALYToken`,
+对应的 appkey 为 `3jRt1atwVuD9dh12`」。
+
+**先探端点**:`GET` 返回 `{"code":200,"message":"成功","content":"<32 位 token>"}`(带不带 appkey
+参数都能拿到,每次新签一个)。**先用它 + 新 appkey 打了一次真实识别** → 结果正确
+("青浦区高新技术企业奖励是多少钱?"),确认这对 token/appkey 可用。
+
+**改法**(前端不再需要 AccessKeySecret):
+- `vite.config.ts`:新增 `/aly-api/` 代理 → `http://121.43.55.7:10011`(与 `/ip-api/`
+  同一台机器、同样的**混合内容**原因:端点只有 HTTP、页面是 HTTPS,必须走同源路径);
+  **删掉**先前那段"自己签 token"的 `/nls-token/` 中间件(前端侧不再需要 AK/SK)
+- `src/network/api/nls-token.ts`:改为调网关,解析 `{code, content}`;网关**不返回过期时间**,
+  按官方文档的 **24 小时**估(`TOKEN_LIFETIME_MS`),缓存到过期前 5 分钟、并发去重照旧
+- `src/utils/runtime-config.ts`:`getNlsTokenApiUrl()`(默认 `/aly-api/proxy/verify/getALYToken`,
+  可用 `VITE_NLS_TOKEN_API` 覆盖)+ `getNlsAppkey()`(`VITE_NLS_APPKEY`)
+- 配置:`VITE_NLS_TOKEN_API` 写进 `.env.development`(同源路径,不是凭据,可入库);
+  **appkey 写进 `.env.development.local`**(gitignored,按"不记录具体值"的口径)
+- harness 侧:`nls-token.mjs` 新增 `resolveNlsToken()` —— **显式 token → 网关 → AK/SK 自签**(退路),
+  探针与验证脚本都改走它(所以现在的验证走的是**生产同一条路**)
+
+**运行过的验证**:
+- `curl https://localhost:8083/aly-api/proxy/verify/getALYToken` → **200 + 网关原文** ✓(代理生效)
+- 旧 `/nls-token/` 已停用(返回 SPA 兜底的 HTML,不再是端点)✓
+- `verify-nls-realtime.mjs` **42/42**,真实识别那一段打印 `(token 来源:gateway)` 且识别正确 ✓
+- dev server 把 `VITE_NLS_APPKEY` 注入到前端模块 ✓(`curl /src/utils/runtime-config.ts` 可见)
+- `npm run build` 通过;`vue-tsc` 无新错误
+
+**🔴 部署必配(已写进技术债 #10 与 asr-nls.md)**:
+① nginx 要把 `/aly-api/` 转发到 `http://121.43.55.7:10011/`(去掉前缀);
+② **`.env.production` / `.env.qingpu` / `.env.test` 都没有 `VITE_NLS_APPKEY`** ——
+实测生产构建的包里 appkey 为空,上线会因空 appkey 失败。**需用户决定**:由部署侧注入,
+还是把 appkey 直接写进那三份 env(它本身不是密钥)。
+
 ### 续 31:把实时语音转文字整理成接口文档(用户要求)
 
 **用户要求**:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。

+ 67 - 0
harness/tools/nls-token.mjs

@@ -33,6 +33,20 @@ export const NLS_TOKEN_HOST = 'https://nls-meta.cn-shanghai.aliyuncs.com/';
 export const NLS_TOKEN_REGION = 'cn-shanghai';
 export const NLS_TOKEN_API_VERSION = '2019-02-28';
 
+/**
+ * 后端网关的 token 端点(**现在的正式取法**,用户 2026-09-24 指定)。
+ *
+ * 返回 `{ code: 200, message: '成功', content: '<32 位 token>' }` ——
+ * 与阿里云 `CreateToken` 相比:**不用 AK/SK**(签名在后端做)、**不返回过期时间**
+ * (NLS token 按文档有效期 24 小时,这里就按 24 小时算)。
+ * 前端走同源前缀(dev 是 vite 代理、生产是 nginx),避免 HTTPS 页面直连 HTTP 被拦。
+ */
+export const NLS_TOKEN_GATEWAY_URL =
+  process.env.NLS_TOKEN_GATEWAY_URL || 'http://121.43.55.7:10011/proxy/verify/getALYToken';
+
+/** NLS token 的有效期(网关不返回过期时间,按官方文档的 24 小时算) */
+export const NLS_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
+
 /** AK 之类的凭据在日志里一律打码(AK 本身也算凭据,别整串落进任何记录) */
 export const maskCredential = (value) => {
   const text = String(value || '');
@@ -140,6 +154,59 @@ export const createNlsToken = async ({
   };
 };
 
+/**
+ * 走后端网关取 token(`getALYToken`)——**现在前端与这套工具都用这条**。
+ * 返回 `{ token, expireAt, requestId }`,形态与 `createNlsToken` 对齐(过期时间按 24h 估)。
+ */
+export const fetchNlsTokenFromGateway = async ({
+  url = NLS_TOKEN_GATEWAY_URL,
+  fetchImpl = fetch,
+} = {}) => {
+  const response = await fetchImpl(url, { method: 'GET' });
+  const text = await response.text();
+  let body;
+  try {
+    body = JSON.parse(text);
+  } catch {
+    throw new Error(`token 网关返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
+  }
+  if (!response.ok || Number(body?.code) !== 200 || !body?.content) {
+    throw new Error(`token 网关取 token 失败(HTTP ${response.status}):${body?.message || text.slice(0, 200)}`);
+  }
+  return {
+    token: String(body.content).trim(),
+    expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS,
+    requestId: body?.requestId || '',
+    source: 'gateway',
+  };
+};
+
+/**
+ * 取一个 token,按优先级挑来源(工具侧统一入口):
+ *   ① 显式给了 token(`NLS_TOKEN`)→ 直接用(手工排查/复现现场最方便)
+ *   ② 有网关地址(默认就是用户指定的那个)→ 走网关
+ *   ③ 否则退回 AK/SK 自己签(老路子,仅作没网关时的退路)
+ */
+export const resolveNlsToken = async ({ env = process.env, fetchImpl = fetch } = {}) => {
+  const explicit = String(env?.NLS_TOKEN || '').trim();
+  if (explicit) {
+    return { token: explicit, expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS, requestId: '', source: 'explicit' };
+  }
+  const gateway = String(env?.NLS_TOKEN_GATEWAY_URL || NLS_TOKEN_GATEWAY_URL).trim();
+  if (gateway) {
+    try {
+      return await fetchNlsTokenFromGateway({ url: gateway, fetchImpl });
+    } catch (error) {
+      // 网关挂了才退回签名;没有 AK/SK 时把网关的错抛出去(别掩盖真因)
+      const credentials = readNlsCredentials(env);
+      if (!credentials.ak || !credentials.sk) throw error;
+      console.warn('[nls] 网关取 token 失败,退回 AK/SK 自签:', error instanceof Error ? error.message : error);
+    }
+  }
+  const credentials = readNlsCredentials(env);
+  return { ...(await createNlsToken(credentials)), source: 'signed' };
+};
+
 /**
  * 带缓存的 token 提供者 —— 给「每个请求都可能问一次」的 dev server 用。
  *

+ 3 - 3
harness/tools/probe-nls-realtime.mjs

@@ -18,7 +18,7 @@ import { existsSync } from 'node:fs';
 import { randomUUID } from 'node:crypto';
 import {
   NLS_LOCAL_ENV_FILE,
-  createNlsToken,
+  resolveNlsToken,
   maskCredential,
   readDotEnvFile,
   readNlsCredentials,
@@ -67,8 +67,8 @@ if (audioFile) {
   log(`音频:${audioFile}(${audioPcm.length} 字节 = ${(audioPcm.length / 32000).toFixed(2)} 秒)${note}`);
 }
 
-const { token, expireAt } = await createNlsToken(credentials);
-log(`token 到手(有效期至 ${new Date(expireAt).toLocaleString('zh-CN')})AK=${maskCredential(credentials.ak)}`);
+const { token, expireAt, source } = await resolveNlsToken();
+log(`token 到手(来源=${source},按 ${new Date(expireAt).toLocaleString('zh-CN')} 过期)appkey=${maskCredential(credentials.appkey)}`);
 
 const url = `${GATEWAY}?token=${encodeURIComponent(token)}`;
 log('连接', url.replace(/token=[^&]+/, 'token=****'));

+ 3 - 2
harness/tools/verify-nls-realtime.mjs

@@ -17,7 +17,7 @@
  */
 
 import { existsSync } from 'node:fs';
-import { createNlsToken, NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials } from './nls-token.mjs';
+import { NLS_LOCAL_ENV_FILE, readDotEnvFile, readNlsCredentials, resolveNlsToken } from './nls-token.mjs';
 import { canSynthesizeSpeech, readAudioAsPcm16kMono, synthesizeSpeechToWav } from './nls-test-audio.mjs';
 
 const {
@@ -174,7 +174,8 @@ if (!credentials.ak || !credentials.sk || !credentials.appkey) {
   const { pcm, note } = readAudioAsPcm16kMono(wav);
   console.log(`  (TTS 音频 ${pcm.length} 字节 ≈ ${(pcm.length / 32000).toFixed(2)}s;${note})`);
 
-  const { token } = await createNlsToken(credentials);
+  const { token, source } = await resolveNlsToken();
+  console.log(`  (token 来源:${source})`);
   const socket = new WebSocket(`${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`);
 
   const seen = { started: false, completed: false, failed: '', mids: 0, fins: 0 };

+ 26 - 20
src/network/api/nls-token.ts

@@ -1,25 +1,29 @@
 /**
- * 取阿里云 NLS 的 token(实时语音识别用)。
+ * 取阿里云 NLS 的 token(实时语音识别用)——**走后端网关**。
  *
- * **前端不签 token**:签名要 AccessKeySecret,而前端 bundle 是公开的 ——
- * 凭据只在服务端(开发环境是 vite 的 `/nls-token/`,见 `harness/tools/nls-token.mjs`),
- * 前端只拿到 `{ token, expireAt, appkey }`。
+ * 端点是 `{VITE_NLS_TOKEN_API}`(默认 `/aly-api/proxy/verify/getALYToken`,同源路径:
+ * dev 由 vite 代理到 `http://121.43.55.7:10011`,生产由 nginx 转发)。
+ * 返回形态:`{ code: 200, message: "成功", content: "<32 位 token>" }`。
  *
- * 缓存策略:token 24 小时有效,缓存到 `expireAt - 5 分钟`;
- * 并发调用共用同一个在途请求(说话时可能连着触发几次)。
+ * **前端不签 token**:签名要 AccessKeySecret,而前端 bundle 是公开的;
+ * 网关侧(或后端)做这件事,前端只拿结果。
+ * 与阿里云 `CreateToken` 的差别:网关**不返回过期时间**,按官方文档的 24 小时估。
+ *
+ * 缓存策略:缓存到过期前 5 分钟;并发调用共用同一个在途请求
+ * (说话时前端可能连着触发几次,不该打多次网关)。
  */
 
-import { getNlsTokenApiBaseUrl } from "@/utils/runtime-config";
+import { getNlsAppkey, getNlsTokenApiUrl } from "@/utils/runtime-config";
 
 export interface NlsTokenResult {
   token: string;
-  /** 过期时间(epoch 毫秒) */
+  /** 过期时间(epoch 毫秒)—— 网关不给,按 24 小时估 */
   expireAt: number;
-  /** 识别请求要带的 appkey;由端点下发,前端不内置 */
-  appkey: string;
 }
 
-/** 提前多久视为过期(与服务端刷新节奏一致) */
+/** NLS token 的有效期(官方文档 24 小时;网关不返回,所以按它估) */
+const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
+/** 提前多久视为过期(留出换 token + 重连的余量) */
 const REFRESH_AHEAD_MS = 5 * 60 * 1000;
 
 let cached: NlsTokenResult | null = null;
@@ -36,26 +40,25 @@ export const fetchNlsToken = async (): Promise<NlsTokenResult> => {
   }
 
   inflight = (async () => {
-    const response = await fetch(`${getNlsTokenApiBaseUrl()}/`, { method: "GET" });
+    const response = await fetch(getNlsTokenApiUrl(), { method: "GET" });
     let body: any = null;
     try {
       body = await response.json();
     } catch {
-      /* 非 JSON 时下面按状态码报错 */
+      /* 非 JSON 时按状态码报错 */
     }
 
     if (!response.ok) {
-      // 服务端会给出可读原因(未配凭据 / 换 token 失败),直接透传给用户看
-      throw new Error(body?.error || `获取语音识别 token 失败(HTTP ${response.status})`);
+      throw new Error(body?.message || `获取语音识别 token 失败(HTTP ${response.status})`);
     }
-    if (!body?.token) {
-      throw new Error("获取语音识别 token 失败:响应里没有 token");
+    // 网关用 code 表达业务结果(HTTP 200 也可能是失败)
+    if (Number(body?.code) !== 200 || !body?.content) {
+      throw new Error(body?.message || "获取语音识别 token 失败:响应里没有 token");
     }
 
     cached = {
-      token: String(body.token),
-      expireAt: Number(body.expireAt) || Date.now() + REFRESH_AHEAD_MS,
-      appkey: String(body.appkey || ""),
+      token: String(body.content).trim(),
+      expireAt: Date.now() + TOKEN_LIFETIME_MS,
     };
     return cached;
   })();
@@ -67,6 +70,9 @@ export const fetchNlsToken = async (): Promise<NlsTokenResult> => {
   }
 };
 
+/** NLS 的 appkey(识别请求要带)—— 见 `getNlsAppkey` 的说明 */
+export const getNlsTokenAppkey = (): string => getNlsAppkey();
+
 /** 丢掉缓存(排查用:怀疑 token 失效时强制重取) */
 export const clearNlsTokenCache = (): void => {
   cached = null;

+ 4 - 3
src/three-libs/asr/nls.ts

@@ -18,7 +18,7 @@
  */
 
 import { BaseEvent } from "@/utils/base-event";
-import { fetchNlsToken } from "@/network/api/nls-token";
+import { fetchNlsToken, getNlsTokenAppkey } from "@/network/api/nls-token";
 import {
   NLS_FLUSH_TICK_MS,
   NLS_FRAME_MS,
@@ -118,8 +118,9 @@ export default class NlsAsr extends BaseEvent {
 
   /** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
   async openSocket(): Promise<void> {
-    const { token, appkey } = await fetchNlsToken();
-    this.appkey = appkey || this.appKey;
+    const { token } = await fetchNlsToken();
+    // appkey 来自环境配置(见 getNlsAppkey);构造参数只作兜底
+    this.appkey = getNlsTokenAppkey() || this.appKey;
     this.taskId = createNlsId();
 
     await new Promise<void>((resolve, reject) => {

+ 22 - 7
src/utils/runtime-config.ts

@@ -37,19 +37,34 @@ export function getDmsApiBaseUrl() {
 }
 
 /**
- * NLS(阿里云智能语音交互)token 端点。
+ * NLS(阿里云智能语音交互)**token 端点**——后端网关提供的 `getALYToken`。
  *
- * 前端**不持有**任何阿里云凭据:token 由服务端用 AccessKeySecret 签名换取后下发
- * (开发环境是 `vite.config.ts` 的 `/nls-token/` 中间件,见 `harness/tools/nls-token.mjs`;
- * 生产要由后端/网关提供同形态的端点 —— 技术债 #10)。
+ * 前端**不持有任何云凭据**:token 由后端网关签发(它自己做阿里云签名),
+ * 前端只拿 token。**必须走同源路径**:网关只有 HTTP,而页面是 HTTPS,
+ * 直连会被混合内容拦掉 —— dev 由 vite 代理 `/aly-api/`(见 vite.config.ts),
+ * 生产由 nginx 做同样的事。
  *
- * 同源相对路径优先(`/nls-token/`),也允许用 env / index.html 注入覆盖。
+ * 返回整条路径(不是前缀),可用 env / index.html 注入覆盖。
  */
-export function getNlsTokenApiBaseUrl() {
+export function getNlsTokenApiUrl() {
   const bootstrap = ((globalThis as any).VITE_NLS_TOKEN_API || "") as string;
   const fromEnv = (import.meta.env.VITE_NLS_TOKEN_API || "") as string;
   const value = bootstrap && bootstrap !== "%VITE_NLS_TOKEN_API%" ? bootstrap : fromEnv;
-  return (value || "/nls-token").trim().replace(/\/+$/, "");
+  return (value || "/aly-api/proxy/verify/getALYToken").trim();
+}
+
+/**
+ * NLS 的 appkey(识别请求里要带)。
+ *
+ * 它**不是密钥**(换 token 不需要它,网关也不校验),但按用户 2026-09-24 的口径
+ * 与其它凭据一样**不写进仓库文件** —— 值放 `.env.development.local`(gitignored),
+ * 生产环境由部署侧注入。
+ */
+export function getNlsAppkey() {
+  const bootstrap = ((globalThis as any).VITE_NLS_APPKEY || "") as string;
+  const fromEnv = (import.meta.env.VITE_NLS_APPKEY || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_NLS_APPKEY%" ? bootstrap : fromEnv;
+  return String(value || "").trim();
 }
 
 /**

+ 14 - 72
vite.config.ts

@@ -14,11 +14,11 @@ import basicSsl from "@vitejs/plugin-basic-ssl";
 // 关键是「每个请求现读」而不是启动时读一次 —— token 约 24 小时过期,以前换一次就要重启 dev server。
 import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from "./harness/tools/dms-token.mjs";
 
-// NLS(阿里云智能语音交互)token 的签名口径收在 harness/tools/nls-token.mjs。
-// **为什么由 dev server 换 token 而不是前端自己签**:签 token 要 AccessKeySecret,
-// 而前端 bundle 是公开的(谁都能拿 secret 签 token、烧账号额度)—— secret 只能待在 node 侧。
-// 生产环境要由后端/网关提供同样的端点,**不能把这段 dev 端点当生产方案**。
-import { createNlsTokenProvider, maskCredential, readNlsCredentials } from "./harness/tools/nls-token.mjs";
+// [已移除] dev 期自己签 NLS token 的中间件(`/nls-token/`)。
+// 2026-09-24 起 token 改由**后端网关**提供:`GET {VITE_ALY_TARGET}/proxy/verify/getALYToken`
+// → `{code:200, content:"<token>"}`,前端走同源 `/aly-api/` 代理拿(见下面的 proxy)。
+// 于是前端侧**不再需要 AccessKeySecret**,vite 也不必持凭据。
+// AK/SK 自签的能力仍留在 harness/tools/nls-token.mjs(工具侧退路,仅在没有网关时用)。
 
 /** 同一个状态只提醒一次,避免每个请求都刷屏 */
 let dmsTokenWarnedFor = "";
@@ -58,74 +58,8 @@ const warnDmsTokenIfNeeded = (token: string): void => {
   }
 };
 
-/** NLS 缺凭据只提醒一次,别每个请求刷屏 */
-let nlsWarnedFor = "";
-
-/**
- * 开发期端点:`GET /nls-token/` → `{ token, expireAt }`。
- *
- * 前端只拿 token,**拿不到 AK/SK**(凭据只在 node 侧用于签名)。
- * token 24 小时过期,提供者内部按 5 分钟提前刷新并做并发去重 ——
- * 所以前端可以放心每次要用就问一次。
- *
- * ⚠️ 仅 dev(`apply: 'serve'`)。生产要后端/网关实现同一形态:
- * 前端仍是同源 `GET /nls-token/`,secret 仍在服务端。
- */
-const nlsTokenEndpoint = (env: Record<string, string>) => {
-  const credentials = readNlsCredentials(env);
-  const provider = createNlsTokenProvider({
-    ak: credentials.ak,
-    sk: credentials.sk,
-    onRefresh: ({ expireAt }) =>
-      console.log(
-        `[nls] token 已刷新,有效期至 ${new Date(expireAt).toLocaleString("zh-CN")}(AK=${maskCredential(credentials.ak)})`
-      ),
-  });
-
-  return {
-    name: "nls-token-endpoint",
-    apply: "serve" as const,
-    configureServer(server: any) {
-      server.middlewares.use("/nls-token", async (_req: any, res: any) => {
-        if (!credentials.ak || !credentials.sk) {
-          if (nlsWarnedFor !== "missing") {
-            nlsWarnedFor = "missing";
-            console.warn(
-              `\n[nls] ⚠️ 没有配置 NLS 凭据,语音识别拿不到 token。\n` +
-                `[nls]    在 .env.development.local(按 .gitignore 不入库)里加:\n` +
-                `[nls]      ALIYUN_NLS_AK=…  ALIYUN_NLS_SK=…  ALIYUN_NLS_APPKEY=…\n` +
-                `[nls]    改完重启 dev server(.env 只在启动时读)。\n`
-            );
-          }
-          res.statusCode = 503;
-          res.setHeader("Content-Type", "application/json; charset=utf-8");
-          res.end(JSON.stringify({ error: "未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)" }));
-          return;
-        }
-        try {
-          const { token, expireAt } = await provider.get();
-          res.setHeader("Content-Type", "application/json; charset=utf-8");
-          // 明确禁止缓存:token 轮换后前端再来问,别被浏览器缓存住旧的
-          res.setHeader("Cache-Control", "no-store");
-          // appkey 一起下发:它要出现在识别请求里,但**没必要打进前端产物** ——
-          // 从端点拿,前端就零内置凭据,将来换 AppKey 也不用重新打包
-          res.end(JSON.stringify({ token, expireAt, appkey: credentials.appkey }));
-        } catch (error) {
-          const message = error instanceof Error ? error.message : String(error);
-          console.warn("[nls] 换 token 失败:", message);
-          res.statusCode = 502;
-          res.setHeader("Content-Type", "application/json; charset=utf-8");
-          res.end(JSON.stringify({ error: message }));
-        }
-      });
-    },
-  };
-};
-
 // https://vitejs.dev/config/
 export default defineConfig(({ mode }) => {
-  // 第三个参数传空串:不加前缀过滤,才能读到 DMS_TOKEN 这类非 VITE_ 前缀的变量
-  // (DMS_TOKEN 只写在 .env.development.local,该文件按 .gitignore 不入库)
   const env = loadEnv(mode, process.cwd(), "");
 
   return {
@@ -142,7 +76,6 @@ export default defineConfig(({ mode }) => {
       }),
       svgLoader(),
       basicSsl(),
-      nlsTokenEndpoint(env),
     ],
     server: {
       proxy: {
@@ -194,6 +127,15 @@ export default defineConfig(({ mode }) => {
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/ip-api/, ""),
         },
+        // 阿里云 NLS 的 token 端点(`/proxy/verify/getALYToken`)——**后端网关提供**,
+        // 与上面 `/ip-api/` 同一台机器(121.43.55.7:10011)、同样只有 HTTP:
+        // 页面是 HTTPS,直连会被混合内容拦掉,所以走同源代理。
+        // 生产要在 nginx 里做同样的事(前端调的是同源路径 `/aly-api/...`)。
+        "/aly-api/": {
+          target: env.VITE_ALY_TARGET || "http://121.43.55.7:10011",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/aly-api/, ""),
+        },
         "/asr/": {
           //匹配的路径
           target: "https://human-screen-v3.metamaker.cn", //目标url