nls-token.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /**
  2. * 阿里云 NLS(智能语音交互)token 的**唯一**获取口径(dev server 与 harness 脚本共用这一份)。
  3. *
  4. * 为什么要有这个模块:NLS 的实时/一句话识别都要求带 `token`(**24 小时过期**),
  5. * 而 token 要用 AccessKeyId + AccessKeySecret 走**阿里云 RPC 签名**(v1.0 / HMAC-SHA1)去换。
  6. * 这套逻辑有两个使用方(开发期端点、命令行工具),签名细节只写一遍。
  7. *
  8. * 🚨 **AccessKeySecret 绝不能进前端产物**(bundle 是公开的,谁都能拿它签 token、烧账号额度)。
  9. * 所以:凭据只在 **node 侧**读(`vite.config.ts` 由它转成 `/nls-token/` 端点;前端只拿 token)。
  10. * 生产环境要换成后端/网关做同一件事——**不要把 dev 端点直接当生产方案**。
  11. *
  12. * 凭据来源:显式的 `env` 参数 → `process.env.ALIYUN_NLS_AK / ALIYUN_NLS_SK / ALIYUN_NLS_APPKEY`
  13. * (开发期写在 `.env.development.local`,该文件按 `.gitignore` 的 `.env.*.local` **不入库**)。
  14. * **任何地方都不要把凭据值写进仓库文件**(用户 2026-09-23 明确要求)。
  15. *
  16. * 签名算法(阿里云 RPC 风格):
  17. * - 公共参数:Action / Version / Format / AccessKeyId / SignatureMethod /
  18. * SignatureVersion / SignatureNonce / Timestamp / RegionId
  19. * - 签名串:`GET&%2F&` + percentEncode(按参数名**字典序**拼好的 query)
  20. * - 签名值:Base64(HMAC-SHA1(secret + "&", stringToSign))
  21. * - percentEncode 比 encodeURIComponent 多替换三处:`+`→%20、`*`→%2A、`%7E`→~
  22. */
  23. import { createHmac, randomUUID } from 'node:crypto';
  24. import { readFileSync } from 'node:fs';
  25. import { dirname, join } from 'node:path';
  26. import { fileURLToPath } from 'node:url';
  27. /** 本机凭据文件(gitignore,仓库根)—— 与 `.dms-token` 同一性质 */
  28. export const NLS_LOCAL_ENV_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env.development.local');
  29. export const NLS_TOKEN_HOST = 'https://nls-meta.cn-shanghai.aliyuncs.com/';
  30. export const NLS_TOKEN_REGION = 'cn-shanghai';
  31. export const NLS_TOKEN_API_VERSION = '2019-02-28';
  32. /** AK 之类的凭据在日志里一律打码(AK 本身也算凭据,别整串落进任何记录) */
  33. export const maskCredential = (value) => {
  34. const text = String(value || '');
  35. return text.length > 8 ? `${text.slice(0, 4)}****${text.slice(-4)}` : text ? '****' : '';
  36. };
  37. /**
  38. * 读凭据。`env` 一般传 `loadEnv(mode, cwd, '')` 的结果(或省略用 process.env)。
  39. * 缺哪个就返回空串,由调用方决定是报错还是降级。
  40. */
  41. export const readNlsCredentials = (env = process.env) => ({
  42. ak: String(env?.ALIYUN_NLS_AK || '').trim(),
  43. sk: String(env?.ALIYUN_NLS_SK || '').trim(),
  44. appkey: String(env?.ALIYUN_NLS_APPKEY || '').trim(),
  45. });
  46. /**
  47. * 读一个 `.env` 风格的文件,只取 `KEY=VALUE`(忽略注释与空行)。
  48. *
  49. * 给**命令行工具**用:vite 侧有 `loadEnv` 读 `.env.development.local`,
  50. * 但脚本直接跑时 `process.env` 里没有这些值 —— 于是 `--check` 会误报"未配置"。
  51. * 优先级仍是**环境变量 > 文件**(CI/临时覆盖更方便)。
  52. */
  53. export const readDotEnvFile = (file) => {
  54. const result = {};
  55. let text = '';
  56. try {
  57. text = readFileSync(file, 'utf8');
  58. } catch {
  59. return result;
  60. }
  61. for (const line of text.split(/\r?\n/)) {
  62. const trimmed = line.trim();
  63. if (!trimmed || trimmed.startsWith('#')) continue;
  64. const eq = trimmed.indexOf('=');
  65. if (eq <= 0) continue;
  66. const key = trimmed.slice(0, eq).trim();
  67. let value = trimmed.slice(eq + 1).trim();
  68. // 去掉包裹的引号(与 dotenv 一致的最简处理)
  69. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  70. value = value.slice(1, -1);
  71. }
  72. result[key] = value;
  73. }
  74. return result;
  75. };
  76. /** 阿里云规定的 percentEncode:在 encodeURIComponent 基础上再替换三处 */
  77. const percentEncode = (value) =>
  78. encodeURIComponent(String(value)).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/%7E/g, '~');
  79. /**
  80. * 换一个 token。成功返回 `{ token, expireAt(ms), requestId }`;
  81. * 失败抛 `Error`,消息里带阿里云的原始 Code/Message(MissingSignature /
  82. * SignatureDoesNotMatch / InvalidAccessKeyId.NotFound / Forbidden.RAM … 都在这)。
  83. */
  84. export const createNlsToken = async ({
  85. ak,
  86. sk,
  87. host = NLS_TOKEN_HOST,
  88. region = NLS_TOKEN_REGION,
  89. fetchImpl = fetch,
  90. } = {}) => {
  91. if (!ak || !sk) {
  92. throw new Error('未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)');
  93. }
  94. // 时间戳必须是 UTC 的 ISO8601(`YYYY-MM-DDTHH:mm:ssZ`),本地时区会直接报 InvalidTimeStamp.Expired
  95. const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
  96. const params = {
  97. AccessKeyId: ak,
  98. Action: 'CreateToken',
  99. Format: 'JSON',
  100. RegionId: region,
  101. SignatureMethod: 'HMAC-SHA1',
  102. SignatureNonce: randomUUID(),
  103. SignatureVersion: '1.0',
  104. Timestamp: timestamp,
  105. Version: NLS_TOKEN_API_VERSION,
  106. };
  107. const canonicalizedQuery = Object.keys(params)
  108. .sort()
  109. .map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
  110. .join('&');
  111. const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonicalizedQuery)}`;
  112. const signature = createHmac('sha1', `${sk}&`).update(stringToSign).digest('base64');
  113. const url = `${host}?Signature=${percentEncode(signature)}&${canonicalizedQuery}`;
  114. const response = await fetchImpl(url, { method: 'GET' });
  115. const text = await response.text();
  116. let body;
  117. try {
  118. body = JSON.parse(text);
  119. } catch {
  120. throw new Error(`NLS 换 token 返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
  121. }
  122. if (!response.ok || !body?.Token?.Id) {
  123. throw new Error(`NLS 换 token 失败(HTTP ${response.status}):${body?.Code || ''} ${body?.Message || text.slice(0, 200)}`);
  124. }
  125. return {
  126. token: String(body.Token.Id),
  127. expireAt: Number(body.Token.ExpireTime) * 1000,
  128. requestId: body.RequestId || body.Requestid || '',
  129. };
  130. };
  131. /**
  132. * 带缓存的 token 提供者 —— 给「每个请求都可能问一次」的 dev server 用。
  133. *
  134. * 为什么必须缓存:token 有 24 小时有效期,而签名+请求每次都要打阿里云;
  135. * 不缓存的话前端每次拉 token 都产生一次计费/限流风险。
  136. * 提前 `refreshAheadMs` 刷新(默认 5 分钟),避免踩在过期边界上。
  137. */
  138. export const createNlsTokenProvider = ({
  139. ak,
  140. sk,
  141. host = NLS_TOKEN_HOST,
  142. region = NLS_TOKEN_REGION,
  143. refreshAheadMs = 5 * 60 * 1000,
  144. fetchImpl = fetch,
  145. onRefresh = null,
  146. } = {}) => {
  147. let cached = null; // { token, expireAt }
  148. let inflight = null; // 并发去重:同一时刻只签一次
  149. const valid = () => cached && cached.expireAt - Date.now() > refreshAheadMs;
  150. return {
  151. /** 拿一个可用 token;缓存有效就直接返回 */
  152. async get() {
  153. if (valid()) return cached;
  154. if (inflight) return inflight;
  155. inflight = createNlsToken({ ak, sk, host, region, fetchImpl })
  156. .then((result) => {
  157. cached = { token: result.token, expireAt: result.expireAt };
  158. onRefresh?.(result);
  159. return cached;
  160. })
  161. .finally(() => {
  162. inflight = null;
  163. });
  164. return inflight;
  165. },
  166. /** 丢掉缓存(排查用:怀疑 token 无效时强制重签) */
  167. invalidate() {
  168. cached = null;
  169. },
  170. };
  171. };