nls-token.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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. * 凭据来源(2026-09-28 起**磁盘上不再保存任何阿里云凭据**):
  13. * - `appkey`:从**代码里的内置值**读(`src/utils/runtime-config.ts` 的 `NLS_APPKEY_BUILTIN`),
  14. * 见 `readBuiltinAppkey()`;`ALIYUN_NLS_APPKEY` 仍可覆盖。
  15. * - `AK/SK`:只在**显式传入**时可用(`ALIYUN_NLS_AK / ALIYUN_NLS_SK` 环境变量),
  16. * 仅用于"网关取不到 token 时自签"的兜底;平时走网关,**不需要它们**。
  17. * **任何地方都不要把凭据值写进仓库文件**(用户 2026-09-23 明确要求)。
  18. * ⚠️ 唯一例外:DMS 的默认服务账号 —— 用户 2026-09-28 要求写进 `dms-login.mjs`(见那里的 ⚠️ 说明)。
  19. * NLS/Aliyun 这边的 AK/SK **仍然不落盘**,不要援引那个例外。
  20. *
  21. * 签名算法(阿里云 RPC 风格):
  22. * - 公共参数:Action / Version / Format / AccessKeyId / SignatureMethod /
  23. * SignatureVersion / SignatureNonce / Timestamp / RegionId
  24. * - 签名串:`GET&%2F&` + percentEncode(按参数名**字典序**拼好的 query)
  25. * - 签名值:Base64(HMAC-SHA1(secret + "&", stringToSign))
  26. * - percentEncode 比 encodeURIComponent 多替换三处:`+`→%20、`*`→%2A、`%7E`→~
  27. */
  28. import { createHmac, randomUUID } from 'node:crypto';
  29. import { readFileSync } from 'node:fs';
  30. import { dirname, join } from 'node:path';
  31. import { fileURLToPath } from 'node:url';
  32. /** 本机凭据文件(gitignore,仓库根)—— 与 `.dms-token` 同一性质。
  33. * ⚠️ 2026-09-28 起它**是空的**(凭据不再落盘),保留常量只为兼容旧调用与提示路径。 */
  34. export const NLS_LOCAL_ENV_FILE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env.development.local');
  35. /** 前端内置 appkey 的所在文件(用户 2026-09-28 要求把 appkey 写进代码) */
  36. export const NLS_APPKEY_SOURCE_FILE = join(
  37. dirname(fileURLToPath(import.meta.url)),
  38. '..',
  39. '..',
  40. 'src',
  41. 'utils',
  42. 'runtime-config.ts'
  43. );
  44. /**
  45. * 读**代码里内置的 appkey**(`NLS_APPKEY_BUILTIN`)。
  46. *
  47. * appkey 不是密钥(换 token 不需要它、网关也不校验它),所以直接写在代码里;
  48. * 脚本从这份唯一来源读,不再要求 `.env.development.local` 里有一份。
  49. * 读不到就返回空串(由调用方决定报错还是降级)。
  50. */
  51. export const readBuiltinAppkey = () => {
  52. try {
  53. const source = readFileSync(NLS_APPKEY_SOURCE_FILE, 'utf8');
  54. const matched = source.match(/NLS_APPKEY_BUILTIN\s*=\s*["']([^"']+)["']/);
  55. return matched ? matched[1].trim() : '';
  56. } catch {
  57. return '';
  58. }
  59. };
  60. export const NLS_TOKEN_HOST = 'https://nls-meta.cn-shanghai.aliyuncs.com/';
  61. export const NLS_TOKEN_REGION = 'cn-shanghai';
  62. export const NLS_TOKEN_API_VERSION = '2019-02-28';
  63. /**
  64. * 后端网关的 token 端点(**现在的正式取法**,用户 2026-09-24 指定)。
  65. *
  66. * 返回 `{ code: 200, message: '成功', content: '<32 位 token>' }` ——
  67. * 与阿里云 `CreateToken` 相比:**不用 AK/SK**(签名在后端做)、**不返回过期时间**
  68. * (NLS token 按文档有效期 24 小时,这里就按 24 小时算)。
  69. * 前端走同源前缀(dev 是 vite 代理、生产是 nginx),避免 HTTPS 页面直连 HTTP 被拦。
  70. */
  71. export const NLS_TOKEN_GATEWAY_URL =
  72. process.env.NLS_TOKEN_GATEWAY_URL || 'http://121.43.55.7:10011/proxy/verify/getALYToken';
  73. /** NLS token 的有效期(网关不返回过期时间,按官方文档的 24 小时算) */
  74. export const NLS_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
  75. /** AK 之类的凭据在日志里一律打码(AK 本身也算凭据,别整串落进任何记录) */
  76. export const maskCredential = (value) => {
  77. const text = String(value || '');
  78. return text.length > 8 ? `${text.slice(0, 4)}****${text.slice(-4)}` : text ? '****' : '';
  79. };
  80. /**
  81. * 读凭据。`env` 一般传 `loadEnv(mode, cwd, '')` 的结果(或省略用 process.env)。
  82. * 缺哪个就返回空串,由调用方决定是报错还是降级。
  83. */
  84. export const readNlsCredentials = (env = process.env) => ({
  85. ak: String(env?.ALIYUN_NLS_AK || '').trim(),
  86. sk: String(env?.ALIYUN_NLS_SK || '').trim(),
  87. // appkey 不再是"凭据":缺 env 就回退到**代码里的内置值**(唯一来源)
  88. appkey: String(env?.ALIYUN_NLS_APPKEY || '').trim() || readBuiltinAppkey(),
  89. });
  90. /**
  91. * 读一个 `.env` 风格的文件,只取 `KEY=VALUE`(忽略注释与空行)。
  92. *
  93. * 给**命令行工具**用:脚本直接跑时 `process.env` 里没有 `.env` 里的值 ——
  94. * 于是 `--check` 会误报"未配置"。优先级仍是**环境变量 > 文件**(CI/临时覆盖更方便)。
  95. *
  96. * ⚠️ 2026-09-28 起 `.env.development.local` **已删除**(凭据不落盘),这个读取器现在
  97. * 只对"你自己新建了某个 `.env.*.local` 想临时用"的场景有意义(文件不存在时返回 `{}`)。
  98. */
  99. export const readDotEnvFile = (file) => {
  100. const result = {};
  101. let text = '';
  102. try {
  103. text = readFileSync(file, 'utf8');
  104. } catch {
  105. return result;
  106. }
  107. for (const line of text.split(/\r?\n/)) {
  108. const trimmed = line.trim();
  109. if (!trimmed || trimmed.startsWith('#')) continue;
  110. const eq = trimmed.indexOf('=');
  111. if (eq <= 0) continue;
  112. const key = trimmed.slice(0, eq).trim();
  113. let value = trimmed.slice(eq + 1).trim();
  114. // 去掉包裹的引号(与 dotenv 一致的最简处理)
  115. if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
  116. value = value.slice(1, -1);
  117. }
  118. result[key] = value;
  119. }
  120. return result;
  121. };
  122. /** 阿里云规定的 percentEncode:在 encodeURIComponent 基础上再替换三处 */
  123. const percentEncode = (value) =>
  124. encodeURIComponent(String(value)).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/%7E/g, '~');
  125. /**
  126. * 换一个 token。成功返回 `{ token, expireAt(ms), requestId }`;
  127. * 失败抛 `Error`,消息里带阿里云的原始 Code/Message(MissingSignature /
  128. * SignatureDoesNotMatch / InvalidAccessKeyId.NotFound / Forbidden.RAM … 都在这)。
  129. */
  130. export const createNlsToken = async ({
  131. ak,
  132. sk,
  133. host = NLS_TOKEN_HOST,
  134. region = NLS_TOKEN_REGION,
  135. fetchImpl = fetch,
  136. } = {}) => {
  137. if (!ak || !sk) {
  138. throw new Error('未配置 NLS 凭据(ALIYUN_NLS_AK / ALIYUN_NLS_SK)');
  139. }
  140. // 时间戳必须是 UTC 的 ISO8601(`YYYY-MM-DDTHH:mm:ssZ`),本地时区会直接报 InvalidTimeStamp.Expired
  141. const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
  142. const params = {
  143. AccessKeyId: ak,
  144. Action: 'CreateToken',
  145. Format: 'JSON',
  146. RegionId: region,
  147. SignatureMethod: 'HMAC-SHA1',
  148. SignatureNonce: randomUUID(),
  149. SignatureVersion: '1.0',
  150. Timestamp: timestamp,
  151. Version: NLS_TOKEN_API_VERSION,
  152. };
  153. const canonicalizedQuery = Object.keys(params)
  154. .sort()
  155. .map((key) => `${percentEncode(key)}=${percentEncode(params[key])}`)
  156. .join('&');
  157. const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonicalizedQuery)}`;
  158. const signature = createHmac('sha1', `${sk}&`).update(stringToSign).digest('base64');
  159. const url = `${host}?Signature=${percentEncode(signature)}&${canonicalizedQuery}`;
  160. const response = await fetchImpl(url, { method: 'GET' });
  161. const text = await response.text();
  162. let body;
  163. try {
  164. body = JSON.parse(text);
  165. } catch {
  166. throw new Error(`NLS 换 token 返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
  167. }
  168. if (!response.ok || !body?.Token?.Id) {
  169. throw new Error(`NLS 换 token 失败(HTTP ${response.status}):${body?.Code || ''} ${body?.Message || text.slice(0, 200)}`);
  170. }
  171. return {
  172. token: String(body.Token.Id),
  173. expireAt: Number(body.Token.ExpireTime) * 1000,
  174. requestId: body.RequestId || body.Requestid || '',
  175. };
  176. };
  177. /**
  178. * 走后端网关取 token(`getALYToken`)——**现在前端与这套工具都用这条**。
  179. * 返回 `{ token, expireAt, requestId }`,形态与 `createNlsToken` 对齐(过期时间按 24h 估)。
  180. */
  181. export const fetchNlsTokenFromGateway = async ({
  182. url = NLS_TOKEN_GATEWAY_URL,
  183. fetchImpl = fetch,
  184. } = {}) => {
  185. const response = await fetchImpl(url, { method: 'GET' });
  186. const text = await response.text();
  187. let body;
  188. try {
  189. body = JSON.parse(text);
  190. } catch {
  191. throw new Error(`token 网关返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
  192. }
  193. if (!response.ok || Number(body?.code) !== 200 || !body?.content) {
  194. throw new Error(`token 网关取 token 失败(HTTP ${response.status}):${body?.message || text.slice(0, 200)}`);
  195. }
  196. return {
  197. token: String(body.content).trim(),
  198. expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS,
  199. requestId: body?.requestId || '',
  200. source: 'gateway',
  201. };
  202. };
  203. /**
  204. * 取一个 token,按优先级挑来源(工具侧统一入口):
  205. * ① 显式给了 token(`NLS_TOKEN`)→ 直接用(手工排查/复现现场最方便)
  206. * ② 有网关地址(默认就是用户指定的那个)→ 走网关
  207. * ③ 否则退回 AK/SK 自己签(老路子,仅作没网关时的退路)
  208. */
  209. export const resolveNlsToken = async ({ env = process.env, fetchImpl = fetch } = {}) => {
  210. const explicit = String(env?.NLS_TOKEN || '').trim();
  211. if (explicit) {
  212. return { token: explicit, expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS, requestId: '', source: 'explicit' };
  213. }
  214. const gateway = String(env?.NLS_TOKEN_GATEWAY_URL || NLS_TOKEN_GATEWAY_URL).trim();
  215. if (gateway) {
  216. try {
  217. return await fetchNlsTokenFromGateway({ url: gateway, fetchImpl });
  218. } catch (error) {
  219. // 网关挂了才退回签名;没有 AK/SK 时把网关的错抛出去(别掩盖真因)
  220. const credentials = readNlsCredentials(env);
  221. if (!credentials.ak || !credentials.sk) throw error;
  222. console.warn('[nls] 网关取 token 失败,退回 AK/SK 自签:', error instanceof Error ? error.message : error);
  223. }
  224. }
  225. const credentials = readNlsCredentials(env);
  226. return { ...(await createNlsToken(credentials)), source: 'signed' };
  227. };
  228. /**
  229. * 带缓存的 token 提供者 —— 给「每个请求都可能问一次」的 dev server 用。
  230. *
  231. * 为什么必须缓存:token 有 24 小时有效期,而签名+请求每次都要打阿里云;
  232. * 不缓存的话前端每次拉 token 都产生一次计费/限流风险。
  233. * 提前 `refreshAheadMs` 刷新(默认 5 分钟),避免踩在过期边界上。
  234. */
  235. export const createNlsTokenProvider = ({
  236. ak,
  237. sk,
  238. host = NLS_TOKEN_HOST,
  239. region = NLS_TOKEN_REGION,
  240. refreshAheadMs = 5 * 60 * 1000,
  241. fetchImpl = fetch,
  242. onRefresh = null,
  243. } = {}) => {
  244. let cached = null; // { token, expireAt }
  245. let inflight = null; // 并发去重:同一时刻只签一次
  246. const valid = () => cached && cached.expireAt - Date.now() > refreshAheadMs;
  247. return {
  248. /** 拿一个可用 token;缓存有效就直接返回 */
  249. async get() {
  250. if (valid()) return cached;
  251. if (inflight) return inflight;
  252. inflight = createNlsToken({ ak, sk, host, region, fetchImpl })
  253. .then((result) => {
  254. cached = { token: result.token, expireAt: result.expireAt };
  255. onRefresh?.(result);
  256. return cached;
  257. })
  258. .finally(() => {
  259. inflight = null;
  260. });
  261. return inflight;
  262. },
  263. /** 丢掉缓存(排查用:怀疑 token 无效时强制重签) */
  264. invalidate() {
  265. cached = null;
  266. },
  267. };
  268. };