Ver código fonte

feat(nls): 语音识别换阿里云 NLS —— 第 1~2 步(token 通路)

用户要求:「接下来我们来实现语音识别功能的更换,一步一步来」,并提供了阿里云 NLS 的
CreateToken 接口与凭据,**明确要求凭据值不要记录**(本提交不含任何凭据值)。

第 1 步 能不能换到 token:能,但必须有 AccessKeySecret —— CreateToken 是阿里云 RPC
签名接口(HMAC-SHA1)。实测链路:只给 AK 时返回 MissingSignature;用 AK/SK 现签拿到 token;
再拿 token + appkey 调一句话识别 /stream/v1/asr → {"status":20000000,"message":"SUCCESS"}
(探针音频是自生成的 440Hz 正弦,result 为空属正常 —— 验的是凭据链路)。

用户拍板:① dev 期由 vite 侧换 token(前端零凭据);② 凭据放 .env.development.local
(已 gitignore,git check-ignore 确认)。

第 2 步 实现:
- 新增 harness/tools/nls-token.mjs:NLS token 的唯一获取口径(v1.0/HMAC-SHA1 签名、
  percentEncode 三处特例、UTC 时间戳、错误带阿里云原始 Code/Message)+ 带 5 分钟提前刷新
  与并发去重的 createNlsTokenProvider()(token 24h 过期,不缓存会每次打阿里云)
- harness/tools/get-nls-token.mjs:命令行壳子(复用上面那份),凭据读环境变量、
  退回 .env.development.local,AK/AppKey 在日志里打码,新增 --check
- vite.config.ts:新增 dev 端点 GET /nls-token/(apply:'serve')→ {token, expireAt, appkey};
  secret 只在 node 侧,前端只拿 token 与 appkey(免重新打包即可换 AppKey);
  缺凭据 503 + warn-once 提示(照 [dms] 那套写)
- 凭据只写在 .env.development.local(gitignored,值不进仓库任何文件)

验证:curl 端点 → 200 {token, expireAt, appkey};拿端点发的 token 真调一次 NLS 识别 → SUCCESS;
npm run build 通过且产物里搜三个凭据值 0 命中;validate-harness 通过。

⚠️ 下一步(未做):NLS 实时识别 WebSocket + 前端取 token 模块,改造时保持现有 ASR 类的
对外契约(openMicroPhone/startRecord/openSocket/transform/close/clear + transform 事件),
两个壳组件不用改。⚠️ 生产要由后端/网关提供同形态端点,已登记技术债 #10。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 mês atrás
pai
commit
940b5557e7

+ 10 - 0
README.md

@@ -413,6 +413,16 @@
   - 读侧不受影响:远端会话列表本来就**排除 `c_is_login="0"` 的访客行**,且只在登录后拉取
   - 验证:`verify-dms-chat-storage.mjs` 78 → **89/89**(打真实 DMS 新增 11 项,含"后续轮次带回
     企业信息时这个纯访客行能正常被填上");变异测试注入旧行为 → 断言大面积失败
+- **语音识别换阿里云 NLS(第 1~2 步:token 通路)**(用户 2026-09-23):NLS 的 token 要用
+  **AccessKeySecret** 签名去换(`CreateToken`,24h 过期),而 secret **不能进前端产物** ——
+  所以走 **dev 端点** `GET /nls-token/`(`vite.config.ts` 的中间件,`apply:'serve'`),
+  由 vite 在 **node 侧**签名并把 `{token, expireAt, appkey}` 交给前端;凭据只写在
+  `.env.development.local`(gitignored,**值不进仓库任何文件**)。
+  - 签名口径收在 `harness/tools/nls-token.mjs`(唯一一份),命令行工具
+    `harness/tools/get-nls-token.mjs` 与 dev 端点共用;带 5 分钟提前刷新 + 并发去重
+  - 验证:端点 200;**拿端点发的 token 真调通一次识别**(`status:20000000 SUCCESS`);
+    `npm run build` 后**产物里搜三个凭据值 0 命中**
+  - ⚠️ 生产要由后端/网关做同样形态的端点(已登记技术债 #10);前端侧接 NLS 实时识别是下一步
 - **实时语音转文字补了一份接口文档**(用户 2026-09-23 要求):
   [`harness/docs/reference/asr-realtime.md`](harness/docs/reference/asr-realtime.md) ——
   SDK 在仓库里(`src/three-libs/asr/index.ts`),文档逐条对源码核对:构造参数与方法、

+ 15 - 0
harness/docs/exec-plans/tech-debt-tracker.md

@@ -131,6 +131,21 @@
   所以也可以**不改构建、只改部署产物里的这一行**——但同样绕不开 ②。
 - **当前状态**:仅登记,未处理。**需要用户决定生产怎么接。**
 
+### 10. 生产的 NLS token 端点还没人做(语音识别换阿里云 NLS)
+
+- **是什么**:语音识别要换成阿里云 NLS,而 NLS 的 token 必须用 **AccessKeySecret** 签名去换
+  (`CreateToken`,24 小时过期)。secret **不能进前端产物**(bundle 是公开的),
+  所以 `vite.config.ts` 里加了一个 **dev-only** 的端点 `GET /nls-token/`
+  (`apply: 'serve'`,返回 `{token, expireAt, appkey}`)。
+- **为什么先欠着**:开发期够用了(前端零凭据,凭据在 `.env.development.local`),
+  但生产没这个端点 —— 打包产物里没有、也不该有任何换 token 的能力。
+- **欠着会怎样**:**生产环境语音识别拿不到 token,功能不可用**(前端会拿到 404/503)。
+- **该还的条件**:后端/网关提供**同样形态**的端点:前端仍是同源
+  `GET /nls-token/` → `{token, expireAt, appkey}`,secret 留在服务端。
+  这样前端的代码一行都不用改,只是 dev 与生产的实现者不同。
+- **当前状态**:dev 已通(端点 200、token 能真调通识别、构建产物零凭据泄漏);
+  生产未做,**需要用户/后端拍板由谁实现**。
+
 ## 已还
 
 ### 访客行会被企业看到(2026-09-22 已还)

+ 44 - 0
harness/progress.md

@@ -3991,6 +3991,50 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
 
 → ✅ **用户 2026-09-23 确认无误**:随便问一轮(未登录、未提公司)在 DMS「助手会话」里确实建了行。
 
+### 续 32:语音识别换阿里云 NLS(第 1~2 步:拿 token 的通路)
+
+**用户要求**:「接下来我们来实现语音识别功能的更换,一步一步来」,给了阿里云 NLS 的
+`CreateToken` 接口与凭据,并**明确要求:凭据的具体值不要记录**。
+
+**第 1 步 · 能不能换到 token**:能。`CreateToken` 是阿里云 RPC 签名接口,需要
+**AccessKeySecret**(第一次只给了 AK,实测返回 `MissingSignature`;缺 Secret 签不出来)。
+用 AK/SK 现签后拿到了 token,并**顺手验证它真能用于识别**:
+拿 token + appkey 调一句话识别 `/stream/v1/asr` → `{"status":20000000,"message":"SUCCESS"}`
+(探针音频是自己生成的 440Hz 正弦,所以 `result` 为空 —— 验的是凭据链路,不是识别质量)。
+
+**用户拍板**:① dev 期走 **vite 侧换 token**(前端零凭据);② 凭据放
+`.env.development.local`(仓库已 gitignore,`git check-ignore` 确认过)。
+
+**第 2 步 · 实现**(前端这一侧还没接,见「下一步」):
+- 新增 `harness/tools/nls-token.mjs`:**NLS token 的唯一获取口径**(签名算法 v1.0/HMAC-SHA1、
+  percentEncode 的三处特例、UTC 时间戳、错误里带阿里云原始 Code/Message),
+  外加 `createNlsTokenProvider()` —— 带 5 分钟提前刷新与并发去重(token 24 小时过期,
+  不缓存每次都要打阿里云)
+- `harness/tools/get-nls-token.mjs` 改成命令行壳子(复用上面那份,新增 `--check`);
+  凭据只从环境变量读,AK 在日志里打码
+- `vite.config.ts` 新增 **dev 端点 `GET /nls-token/`**(`apply: 'serve'`)→
+  `{ token, expireAt, appkey }`;缺凭据时 503 + 明确提示(照 `[dms]` 那套 warn-once 的写法)。
+  **secret 只在 node 侧**:前端只拿 token(和 appkey —— 它要出现在识别请求里,
+  但没必要打进 bundle,从端点拿就能免重新打包)
+- 凭据写入 `.env.development.local`(**gitignored,值不进仓库任何文件**):
+
+**运行过的验证**:
+- `curl -sk https://localhost:8083/nls-token/` → **200**,返回 `{token, expireAt, appkey}` ✓
+  (vite 检测到配置变化自动重启后生效)
+- **拿端点发的 token 真调一次 NLS 识别** → `status:20000000 SUCCESS` ✓(端到端通了)
+- `npm run build` 通过;**产物里搜三个凭据值 → 0 个文件命中** ✓("secret 不进前端"这条有实证)
+- `git check-ignore .env.development.local` 命中 `.gitignore:21:.env.*.local` ✓;
+  `git status` 里看不到该文件 ✓
+
+**⚠️ 下一步(未做)**:NLS **实时识别 WebSocket**(`wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=…`,
+`StartTranscription` JSON 帧 + 二进制音频帧 + `TranscriptionResultChanged`/`SentenceEnd` 事件),
+以及前端取 token 的小模块。改造原则:**保持现有 `ASR` 类的对外契约**
+(`openMicroPhone` / `startRecord` / `openSocket` / `transform()` / `close` / `clear` +
+`transform` 事件),这样两个壳组件一行都不用改。
+
+**⚠️ 生产必须另做**:`/nls-token/` 是 **dev-only**(`apply:'serve'`)。生产要由后端/网关提供
+同样形态的端点(前端仍是同源 `GET /nls-token/`,secret 仍在服务端)。已登记为技术债 #10。
+
 ### 续 31:把实时语音转文字整理成接口文档(用户要求)
 
 **用户要求**:「将当前实时语音转文字的功能整理成一份 API 文档放在 harness 文件夹里面」。

+ 50 - 0
harness/tools/get-nls-token.mjs

@@ -0,0 +1,50 @@
+/**
+ * 换一个阿里云 NLS(智能语音交互)的 token —— 命令行版。
+ *
+ * 签名与缓存逻辑在 `nls-token.mjs`(dev server 的 `/nls-token/` 端点用同一份)。
+ *
+ * 怎么跑(凭据只从环境变量读,脚本里不存任何值):
+ *   ALIYUN_NLS_AK=xxx ALIYUN_NLS_SK=yyy node harness/tools/get-nls-token.mjs
+ *   # 只想拿 token 本身(给别的命令用):... | tail -1
+ *
+ * 也可以只跑 `--check` 看当前配置状态(不请求阿里云、不打凭据值)。
+ *
+ * ⚠️ 凭据值**不要写进任何仓库文件**(用户 2026-09-23 明确要求)。
+ */
+
+import {
+  NLS_LOCAL_ENV_FILE,
+  createNlsToken,
+  maskCredential,
+  readDotEnvFile,
+  readNlsCredentials,
+} from './nls-token.mjs';
+
+// 环境变量优先;没有就退回本机凭据文件(vite 的 loadEnv 读的是同一个文件)
+const credentials = readNlsCredentials({ ...readDotEnvFile(NLS_LOCAL_ENV_FILE), ...process.env });
+
+if (process.argv.includes('--check')) {
+  console.log(`[nls] AK=${credentials.ak ? maskCredential(credentials.ak) : '(未配置)'}`);
+  console.log(`[nls] SK=${credentials.sk ? '(已配置,不显示)' : '(未配置)'}`);
+  console.log(`[nls] AppKey=${credentials.appkey ? maskCredential(credentials.appkey) : '(未配置)'}`);
+  process.exit(credentials.ak && credentials.sk ? 0 : 2);
+}
+
+if (!credentials.ak || !credentials.sk) {
+  console.error('缺凭据。用法:ALIYUN_NLS_AK=xxx ALIYUN_NLS_SK=yyy node harness/tools/get-nls-token.mjs');
+  process.exit(2);
+}
+
+console.log(`[nls] 请求 CreateToken  AK=${maskCredential(credentials.ak)}`);
+
+try {
+  const { token, expireAt, requestId } = await createNlsToken(credentials);
+  const expireDate = new Date(expireAt);
+  console.log(`[nls] token 有效期至 ${expireDate.toISOString()}(本地 ${expireDate.toLocaleString('zh-CN')})`);
+  if (requestId) console.log(`[nls] RequestId=${requestId}`);
+  // 最后一行只打 token,方便 `| tail -1` 取用
+  console.log(token);
+} catch (error) {
+  console.error('[nls] 换 token 失败:', error instanceof Error ? error.message : error);
+  process.exit(1);
+}

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

@@ -0,0 +1,185 @@
+/**
+ * 阿里云 NLS(智能语音交互)token 的**唯一**获取口径(dev server 与 harness 脚本共用这一份)。
+ *
+ * 为什么要有这个模块:NLS 的实时/一句话识别都要求带 `token`(**24 小时过期**),
+ * 而 token 要用 AccessKeyId + AccessKeySecret 走**阿里云 RPC 签名**(v1.0 / HMAC-SHA1)去换。
+ * 这套逻辑有两个使用方(开发期端点、命令行工具),签名细节只写一遍。
+ *
+ * 🚨 **AccessKeySecret 绝不能进前端产物**(bundle 是公开的,谁都能拿它签 token、烧账号额度)。
+ * 所以:凭据只在 **node 侧**读(`vite.config.ts` 由它转成 `/nls-token/` 端点;前端只拿 token)。
+ * 生产环境要换成后端/网关做同一件事——**不要把 dev 端点直接当生产方案**。
+ *
+ * 凭据来源:显式的 `env` 参数 → `process.env.ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY`
+ * (开发期写在 `.env.development.local`,该文件按 `.gitignore` 的 `.env.*.local` **不入库**)。
+ * **任何地方都不要把凭据值写进仓库文件**(用户 2026-09-23 明确要求)。
+ *
+ * 签名算法(阿里云 RPC 风格):
+ *   - 公共参数:Action / Version / Format / AccessKeyId / SignatureMethod /
+ *     SignatureVersion / SignatureNonce / Timestamp / RegionId
+ *   - 签名串:`GET&%2F&` + percentEncode(按参数名**字典序**拼好的 query)
+ *   - 签名值:Base64(HMAC-SHA1(secret + "&", stringToSign))
+ *   - percentEncode 比 encodeURIComponent 多替换三处:`+`→%20、`*`→%2A、`%7E`→~
+ */
+
+import { createHmac, randomUUID } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/** 本机凭据文件(gitignore,仓库根)—— 与 `.dms-token` 同一性质 */
+export const NLS_LOCAL_ENV_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env.development.local');
+
+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';
+
+/** AK 之类的凭据在日志里一律打码(AK 本身也算凭据,别整串落进任何记录) */
+export const maskCredential = (value) => {
+  const text = String(value || '');
+  return text.length > 8 ? `${text.slice(0, 4)}****${text.slice(-4)}` : text ? '****' : '';
+};
+
+/**
+ * 读凭据。`env` 一般传 `loadEnv(mode, cwd, '')` 的结果(或省略用 process.env)。
+ * 缺哪个就返回空串,由调用方决定是报错还是降级。
+ */
+export const readNlsCredentials = (env = process.env) => ({
+  ak: String(env?.ALIYUN_NLS_AK || '').trim(),
+  sk: String(env?.ALIYUN_NLS_SK || '').trim(),
+  appkey: String(env?.ALIYUN_NLS_APPKEY || '').trim(),
+});
+
+/**
+ * 读一个 `.env` 风格的文件,只取 `KEY=VALUE`(忽略注释与空行)。
+ *
+ * 给**命令行工具**用:vite 侧有 `loadEnv` 读 `.env.development.local`,
+ * 但脚本直接跑时 `process.env` 里没有这些值 —— 于是 `--check` 会误报"未配置"。
+ * 优先级仍是**环境变量 > 文件**(CI/临时覆盖更方便)。
+ */
+export const readDotEnvFile = (file) => {
+  const result = {};
+  let text = '';
+  try {
+    text = readFileSync(file, 'utf8');
+  } catch {
+    return result;
+  }
+  for (const line of text.split(/\r?\n/)) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#')) continue;
+    const eq = trimmed.indexOf('=');
+    if (eq <= 0) continue;
+    const key = trimmed.slice(0, eq).trim();
+    let value = trimmed.slice(eq + 1).trim();
+    // 去掉包裹的引号(与 dotenv 一致的最简处理)
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+      value = value.slice(1, -1);
+    }
+    result[key] = value;
+  }
+  return result;
+};
+
+/** 阿里云规定的 percentEncode:在 encodeURIComponent 基础上再替换三处 */
+const percentEncode = (value) =>
+  encodeURIComponent(String(value)).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/%7E/g, '~');
+
+/**
+ * 换一个 token。成功返回 `{ token, expireAt(ms), requestId }`;
+ * 失败抛 `Error`,消息里带阿里云的原始 Code/Message(MissingSignature /
+ * SignatureDoesNotMatch / InvalidAccessKeyId.NotFound / Forbidden.RAM … 都在这)。
+ */
+export const createNlsToken = async ({
+  ak,
+  sk,
+  host = NLS_TOKEN_HOST,
+  region = NLS_TOKEN_REGION,
+  fetchImpl = fetch,
+} = {}) => {
+  if (!ak || !sk) {
+    throw new Error('未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)');
+  }
+
+  // 时间戳必须是 UTC 的 ISO8601(`YYYY-MM-DDTHH:mm:ssZ`),本地时区会直接报 InvalidTimeStamp.Expired
+  const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
+  const params = {
+    AccessKeyId: ak,
+    Action: 'CreateToken',
+    Format: 'JSON',
+    RegionId: region,
+    SignatureMethod: 'HMAC-SHA1',
+    SignatureNonce: randomUUID(),
+    SignatureVersion: '1.0',
+    Timestamp: timestamp,
+    Version: NLS_TOKEN_API_VERSION,
+  };
+
+  const canonicalizedQuery = Object.keys(params)
+    .sort()
+    .map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
+    .join('&');
+  const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonicalizedQuery)}`;
+  const signature = createHmac('sha1', `${sk}&`).update(stringToSign).digest('base64');
+  const url = `${host}?Signature=${percentEncode(signature)}&${canonicalizedQuery}`;
+
+  const response = await fetchImpl(url, { method: 'GET' });
+  const text = await response.text();
+  let body;
+  try {
+    body = JSON.parse(text);
+  } catch {
+    throw new Error(`NLS 换 token 返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
+  }
+  if (!response.ok || !body?.Token?.Id) {
+    throw new Error(`NLS 换 token 失败(HTTP ${response.status}):${body?.Code || ''} ${body?.Message || text.slice(0, 200)}`);
+  }
+  return {
+    token: String(body.Token.Id),
+    expireAt: Number(body.Token.ExpireTime) * 1000,
+    requestId: body.RequestId || body.Requestid || '',
+  };
+};
+
+/**
+ * 带缓存的 token 提供者 —— 给「每个请求都可能问一次」的 dev server 用。
+ *
+ * 为什么必须缓存:token 有 24 小时有效期,而签名+请求每次都要打阿里云;
+ * 不缓存的话前端每次拉 token 都产生一次计费/限流风险。
+ * 提前 `refreshAheadMs` 刷新(默认 5 分钟),避免踩在过期边界上。
+ */
+export const createNlsTokenProvider = ({
+  ak,
+  sk,
+  host = NLS_TOKEN_HOST,
+  region = NLS_TOKEN_REGION,
+  refreshAheadMs = 5 * 60 * 1000,
+  fetchImpl = fetch,
+  onRefresh = null,
+} = {}) => {
+  let cached = null; // { token, expireAt }
+  let inflight = null; // 并发去重:同一时刻只签一次
+
+  const valid = () => cached && cached.expireAt - Date.now() > refreshAheadMs;
+
+  return {
+    /** 拿一个可用 token;缓存有效就直接返回 */
+    async get() {
+      if (valid()) return cached;
+      if (inflight) return inflight;
+      inflight = createNlsToken({ ak, sk, host, region, fetchImpl })
+        .then((result) => {
+          cached = { token: result.token, expireAt: result.expireAt };
+          onRefresh?.(result);
+          return cached;
+        })
+        .finally(() => {
+          inflight = null;
+        });
+      return inflight;
+    },
+    /** 丢掉缓存(排查用:怀疑 token 无效时强制重签) */
+    invalidate() {
+      cached = null;
+    },
+  };
+};

+ 71 - 0
vite.config.ts

@@ -14,6 +14,12 @@ 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";
+
 /** 同一个状态只提醒一次,避免每个请求都刷屏 */
 let dmsTokenWarnedFor = "";
 
@@ -52,6 +58,70 @@ 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_ 前缀的变量
@@ -72,6 +142,7 @@ export default defineConfig(({ mode }) => {
       }),
       svgLoader(),
       basicSsl(),
+      nlsTokenEndpoint(env),
     ],
     server: {
       proxy: {