nls-token.mjs 10 KB

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