/** * 阿里云 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; }, }; };