| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- /**
- * 阿里云 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';
- /**
- * 后端网关的 token 端点(**现在的正式取法**,用户 2026-09-24 指定)。
- *
- * 返回 `{ code: 200, message: '成功', content: '<32 位 token>' }` ——
- * 与阿里云 `CreateToken` 相比:**不用 AK/SK**(签名在后端做)、**不返回过期时间**
- * (NLS token 按文档有效期 24 小时,这里就按 24 小时算)。
- * 前端走同源前缀(dev 是 vite 代理、生产是 nginx),避免 HTTPS 页面直连 HTTP 被拦。
- */
- export const NLS_TOKEN_GATEWAY_URL =
- process.env.NLS_TOKEN_GATEWAY_URL || 'http://121.43.55.7:10011/proxy/verify/getALYToken';
- /** NLS token 的有效期(网关不返回过期时间,按官方文档的 24 小时算) */
- export const NLS_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
- /** 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(`getALYToken`)——**现在前端与这套工具都用这条**。
- * 返回 `{ token, expireAt, requestId }`,形态与 `createNlsToken` 对齐(过期时间按 24h 估)。
- */
- export const fetchNlsTokenFromGateway = async ({
- url = NLS_TOKEN_GATEWAY_URL,
- fetchImpl = fetch,
- } = {}) => {
- const response = await fetchImpl(url, { method: 'GET' });
- const text = await response.text();
- let body;
- try {
- body = JSON.parse(text);
- } catch {
- throw new Error(`token 网关返回的不是 JSON(HTTP ${response.status}):${text.slice(0, 200)}`);
- }
- if (!response.ok || Number(body?.code) !== 200 || !body?.content) {
- throw new Error(`token 网关取 token 失败(HTTP ${response.status}):${body?.message || text.slice(0, 200)}`);
- }
- return {
- token: String(body.content).trim(),
- expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS,
- requestId: body?.requestId || '',
- source: 'gateway',
- };
- };
- /**
- * 取一个 token,按优先级挑来源(工具侧统一入口):
- * ① 显式给了 token(`NLS_TOKEN`)→ 直接用(手工排查/复现现场最方便)
- * ② 有网关地址(默认就是用户指定的那个)→ 走网关
- * ③ 否则退回 AK/SK 自己签(老路子,仅作没网关时的退路)
- */
- export const resolveNlsToken = async ({ env = process.env, fetchImpl = fetch } = {}) => {
- const explicit = String(env?.NLS_TOKEN || '').trim();
- if (explicit) {
- return { token: explicit, expireAt: Date.now() + NLS_TOKEN_LIFETIME_MS, requestId: '', source: 'explicit' };
- }
- const gateway = String(env?.NLS_TOKEN_GATEWAY_URL || NLS_TOKEN_GATEWAY_URL).trim();
- if (gateway) {
- try {
- return await fetchNlsTokenFromGateway({ url: gateway, fetchImpl });
- } catch (error) {
- // 网关挂了才退回签名;没有 AK/SK 时把网关的错抛出去(别掩盖真因)
- const credentials = readNlsCredentials(env);
- if (!credentials.ak || !credentials.sk) throw error;
- console.warn('[nls] 网关取 token 失败,退回 AK/SK 自签:', error instanceof Error ? error.message : error);
- }
- }
- const credentials = readNlsCredentials(env);
- return { ...(await createNlsToken(credentials)), source: 'signed' };
- };
- /**
- * 带缓存的 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;
- },
- };
- };
|