word-audio.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. // This module is assembled into the authenticated app gateway. No credentials reach clients.
  2. async function wordAudioConfig() {
  3. const config=await Parse.Config.get({useMasterKey:true}),env=typeof process!=='undefined'?process.env||{}:{};
  4. const pick=(key,fallback='')=>String(env[key]||config.get(key)||fallback).trim();
  5. const limit=(key,value,max)=>Math.max(1,Math.min(max,Number(pick(key,value))||value));
  6. return {enabled:pick('XIAOSHU_TTS_ENABLED')==='true',appId:pick('XFYUN_APP_ID'),apiKey:pick('XFYUN_API_KEY'),apiSecret:pick('XFYUN_API_SECRET'),
  7. workerUrl:pick('XIAOSHU_TTS_WORKER_URL'),workerToken:pick('XIAOSHU_TTS_WORKER_TOKEN'),voice:pick('XFYUN_TTS_VCN'),speed:50,dayLimit:limit('XIAOSHU_TTS_DAILY_LIMIT',500,100000),minuteLimit:limit('XIAOSHU_TTS_USER_MINUTE_LIMIT',30,100),concurrency:limit('XIAOSHU_TTS_CONCURRENCY',1,20),
  8. storage:{endpoint:pick('XIAOSHU_S3_ENDPOINT').replace(/\/$/,''),bucket:pick('XIAOSHU_S3_BUCKET'),region:pick('XIAOSHU_S3_REGION','auto'),accessKey:pick('XIAOSHU_S3_ACCESS_KEY_ID'),secretKey:pick('XIAOSHU_S3_SECRET_ACCESS_KEY'),publicBase:pick('XIAOSHU_S3_PUBLIC_BASE_URL').replace(/\/$/,'')}};
  9. }
  10. function wordAudioText(value) { return String(value||'').normalize('NFC').trim().replace(/\s+/g,' '); }
  11. function wordAudioHex(bytes) { return Array.from(new Uint8Array(bytes),v=>v.toString(16).padStart(2,'0')).join(''); }
  12. async function wordAudioHash(value) { return wordAudioHex(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value))); }
  13. async function wordAudioHmac(key,value) {
  14. const imported=await crypto.subtle.importKey('raw',typeof key==='string'?new TextEncoder().encode(key):key,{name:'HMAC',hash:'SHA-256'},false,['sign']);
  15. return crypto.subtle.sign('HMAC',imported,new TextEncoder().encode(value));
  16. }
  17. function wordAudioBase64(bytes) { let binary='';for(const byte of new Uint8Array(bytes))binary+=String.fromCharCode(byte);return btoa(binary); }
  18. function wordAudioBytes(base64) { return Uint8Array.from(atob(base64),c=>c.charCodeAt(0)); }
  19. function wordAudioUrl(raw) {
  20. const value=String(raw||'').trim().replace(/\\/g,'/');if(!value)return '';
  21. if(/^https?:\/\//i.test(value))return value.replace(/^http:\/\/a018\.2018\.z01\.com/i,'https://a018.2018.z01.com');
  22. if(value.startsWith('//'))return 'https:'+value;
  23. if(/^[a-z]+:/i.test(value))return value;
  24. return 'https://a018.2018.z01.com/UploadFiles/'+value.replace(/^\/?(?:backend\/)?(?:UploadFiles\/)?/i,'');
  25. }
  26. async function wordAudioMissing(url,config) {
  27. // Never probe client URLs, arbitrary hosts, or redirects into private networks.
  28. let target;try{target=new URL(url);}catch(_){return false;}
  29. const hosts=new Set(['https://a018.2018.z01.com']);
  30. if(config.storage.publicBase){try{hosts.add(new URL(config.storage.publicBase).origin);}catch(_){}}
  31. if(target.protocol!=='https:'||target.username||target.password||!hosts.has(target.origin))return false;
  32. try {
  33. const response=await fetch(url,{method:'GET',headers:{Range:'bytes=0-31'},redirect:'manual',signal:AbortSignal.timeout(5000)});
  34. await response.body?.cancel();return response.status===404||response.status===410;
  35. } catch(_) {return false;}
  36. }
  37. async function wordAudioTarget(input,current) {
  38. if(!current||current.get('company')?.id!==DEFAULT_COMPANY_ID||current.get('isDisabled')===true||current.get('isDeleted')===true)fail(403,'无权访问词库读音');
  39. if(![1,3].includes(Number(current.get('legacyGroupId')))&&!isAdminUser(current))fail(403,'无权生成词库读音');
  40. const id=String(input.wordId||'');if(!/^\d{1,16}$/.test(id))fail(400,'缺少有效词条 ID');
  41. // Model 52 is public vocabulary in the existing access model; drafts/deleted records are excluded here.
  42. const row=await Psql.oneOrNone('SELECT c."generalId",c.title,v."objectId" AS "vocabularyId",v.yb,v.yp FROM "CommonModel" c JOIN "VocabularyWord" v ON v.company=c.company AND v.id=c."itemId" WHERE c.company=$1 AND c."modelId"=52 AND c."generalId"::text=$2 AND c.status=99 LIMIT 1',[DEFAULT_COMPANY_ID,id]);
  43. if(!row)fail(404,'词条不存在或尚未发布');return row;
  44. }
  45. function wordAudioValidMp3(audio) {
  46. try{const bytes=wordAudioBytes(audio);return bytes.length>=4&&bytes.length<=512000&&(String.fromCharCode(...bytes.slice(0,3))==='ID3'||bytes[0]===255&&(bytes[1]&224)===224);}catch(_){return false;}
  47. }
  48. async function wordAudioSynthesize(word,config) {
  49. if(typeof WebSocket==='undefined') {
  50. if(!config.workerUrl?.startsWith('https://')||!config.workerToken)fail(503,'读音合成适配器尚未配置');
  51. let response;try{response=await fetch(config.workerUrl,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+config.workerToken},body:JSON.stringify({word,voice:config.voice,speed:config.speed}),redirect:'error',signal:AbortSignal.timeout(25000)});}catch(_){fail(502,'读音合成服务连接失败');}
  52. if(!response.ok)fail(502,'读音合成服务暂时不可用');
  53. const value=await response.json(),audio=String(value.audio||'');
  54. if(audio.length>700000||!wordAudioValidMp3(audio))fail(502,'读音合成服务返回无效音频');return audio;
  55. }
  56. const host='tts-api.xfyun.cn',date=new Date().toUTCString();
  57. const signature=wordAudioBase64(await wordAudioHmac(config.apiSecret,'host: '+host+'\ndate: '+date+'\nGET /v2/tts HTTP/1.1'));
  58. const auth=btoa('api_key="'+config.apiKey+'", algorithm="hmac-sha256", headers="host date request-line", signature="'+signature+'"');
  59. const url='wss://'+host+'/v2/tts?'+new URLSearchParams({authorization:auth,date,host});
  60. return new Promise((resolve,reject)=>{
  61. const socket=new WebSocket(url),chunks=[];let length=0,settled=false;
  62. const finish=(error)=>{if(settled)return;settled=true;clearTimeout(timer);try{socket.close();}catch(_){}if(error)return reject(error);
  63. const bytes=new Uint8Array(length);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}
  64. if(length<4||!(String.fromCharCode(...bytes.slice(0,3))==='ID3'||bytes[0]===255&&(bytes[1]&224)===224))return reject(new Error('讯飞未返回有效 MP3'));resolve(wordAudioBase64(bytes));};
  65. const timer=setTimeout(()=>finish(new Error('读音生成超时,请稍后重试')),20000);
  66. socket.onopen=()=>{try{socket.send(JSON.stringify({common:{app_id:config.appId},business:{aue:'lame',sfl:1,auf:'audio/L16;rate=16000',vcn:config.voice,speed:config.speed,tte:'UTF8',reg:'0',bgs:0},data:{status:2,text:wordAudioBase64(new TextEncoder().encode(word))}}));}catch(_){finish(new Error('读音服务连接失败'));}};
  67. socket.onmessage=event=>{try{const message=JSON.parse(event.data);if(message.code!==0)return finish(new Error('讯飞合成失败('+Number(message.code)+')'));
  68. if(message.data?.audio){const bytes=wordAudioBytes(message.data.audio);length+=bytes.length;if(length>512000)return finish(new Error('读音文件过大'));chunks.push(bytes);}
  69. if(message.data?.status===2)finish();
  70. }catch(_){finish(new Error('读音服务返回无效数据'));}};
  71. socket.onerror=()=>finish(new Error('读音服务连接失败'));socket.onclose=()=>{if(!settled)finish(new Error('读音生成中断,请稍后重试'));};
  72. });
  73. }
  74. function wordAudioStorageReady(storage) {
  75. try {return Object.values(storage).every(Boolean)&&new URL(storage.endpoint).protocol==='https:'&&new URL(storage.publicBase).protocol==='https:'&&/^[a-zA-Z0-9._-]+$/.test(storage.bucket);}catch(_){return false;}
  76. }
  77. async function wordAudioUpload(id,base64,config) {
  78. if(!wordAudioStorageReady(config.storage)) {
  79. if(!wordAudioValidMp3(base64))fail(502,'读音文件无效');
  80. return uploadAppMedia({fileName:id+'.mp3',contentType:'audio/mpeg',base64},{'audio/mpeg':''});
  81. }
  82. const s=config.storage,key=DEFAULT_COMPANY_ID+'/audio/words/'+id+'.mp3',stamp=new Date().toISOString().replace(/[:-]|\.\d{3}/g,''),day=stamp.slice(0,8);
  83. const path='/'+s.bucket+'/'+key,scope=day+'/'+s.region+'/s3/aws4_request',host=new URL(s.endpoint).host;
  84. const query='X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential='+encodeURIComponent(s.accessKey+'/'+scope)+'&X-Amz-Date='+stamp+'&X-Amz-Expires=60&X-Amz-SignedHeaders=host';
  85. const canonical='PUT\n'+path+'\n'+query+'\nhost:'+host+'\n\nhost\nUNSIGNED-PAYLOAD';
  86. const signed='AWS4-HMAC-SHA256\n'+stamp+'\n'+scope+'\n'+await wordAudioHash(canonical);
  87. const dateKey=await wordAudioHmac('AWS4'+s.secretKey,day),regionKey=await wordAudioHmac(dateKey,s.region),serviceKey=await wordAudioHmac(regionKey,'s3'),signingKey=await wordAudioHmac(serviceKey,'aws4_request');
  88. const signature=wordAudioHex(await wordAudioHmac(signingKey,signed));
  89. const response=await fetch(s.endpoint+path+'?'+query+'&X-Amz-Signature='+signature,{method:'PUT',headers:{'Content-Type':'audio/mpeg'},body:wordAudioBytes(base64),redirect:'error',signal:AbortSignal.timeout(10000)});
  90. await response.body?.cancel();if(!response.ok)fail(502,'读音已生成,保存失败,请稍后重试');return s.publicBase+'/'+key;
  91. }
  92. async function wordAudioBind(row,url) {
  93. // Compare-and-set: never overwrite an editor's newer audio or a changed word/phonetic.
  94. const changed=await Psql.query('UPDATE "VocabularyWord" v SET yp=$1,"updatedAt"=now() FROM "CommonModel" c WHERE v."objectId"=$2 AND v.company=$3 AND c.company=v.company AND c."modelId"=52 AND c."itemId"=v.id AND c."generalId"::text=$4 AND c.title=$5 AND COALESCE(v.yb,\'\')=$6 AND COALESCE(v.yp,\'\')=$7 RETURNING v.yp',[url,row.vocabularyId,DEFAULT_COMPANY_ID,String(row.generalId),row.title,row.yb||'',row.yp||'']);
  95. if(!changed.length)fail(409,'词条已更新,请刷新后点读');
  96. return {state:'ready',url,source:'xfyun'};
  97. }
  98. async function appWordAudioEnsure(input,current) {
  99. const row=await wordAudioTarget(input,current),oldUrl=wordAudioUrl(row.yp);
  100. if(oldUrl&&input.playbackFailed!==true)return {state:'ready',url:oldUrl,source:'existing'};
  101. const config=await wordAudioConfig();
  102. if(oldUrl&&!await wordAudioMissing(oldUrl,config))return {state:'ready',url:oldUrl,source:'existing'};
  103. const word=wordAudioText(row.title),phonetic=wordAudioText(row.yb);
  104. if(!word||word.length>200||/[\x00-\x1f<>\[\]{}]/.test(word))fail(400,'该词条暂不支持自动读音');
  105. const voice=JSON.stringify({provider:'xfyun-v2',vcn:config.voice,accent:'en-US',speed:config.speed,format:'mp3',version:1});
  106. const id=await wordAudioHash(JSON.stringify([DEFAULT_COMPANY_ID,word,phonetic,voice]));
  107. let cached=await Psql.oneOrNone('SELECT * FROM "WordAudioCache" WHERE "objectId"=$1 AND company=$2',[id,DEFAULT_COMPANY_ID]);
  108. if(cached?.status==='ready'){
  109. if(oldUrl&&oldUrl===cached.url){await Psql.none('UPDATE "WordAudioCache" SET status=\'pending\',"updatedAt"=now() WHERE "objectId"=$1 AND status=\'ready\'',[id]);}
  110. else return wordAudioBind(row,cached.url);
  111. }
  112. if(!config.enabled)fail(503,'自动读音尚未开启,请联系老师');
  113. if(!config.voice)fail(503,'自动读音尚未配置完成');
  114. if(typeof WebSocket==='undefined') {if(!config.workerUrl?.startsWith('https://')||!config.workerToken)fail(503,'读音合成适配器尚未配置');}
  115. else if(!config.appId||!config.apiKey||!config.apiSecret)fail(503,'自动读音尚未配置完成');
  116. if(!wordAudioStorageReady(config.storage)) {const legacy=await appLegacyConfig();if(!legacy.url||!legacy.apiId||!legacy.apiKey)fail(503,'读音存储尚未配置');}
  117. const token=randomObjectId();
  118. const {result}=await Psql.one('SELECT xs_word_audio_claim($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[DEFAULT_COMPANY_ID,id,current.id,token,word,phonetic,voice,config.dayLimit,config.minuteLimit,config.concurrency]);
  119. if(result.state==='ready')return wordAudioBind(row,result.url);
  120. if(result.state==='pending')return {state:'pending',retryAfterMs:1500};
  121. if(result.state==='quota')fail(429,'今日自动读音额度已用完,请稍后再试');
  122. if(result.state==='limited')fail(429,'点读太频繁,请稍后再试');
  123. if(result.state==='retry')fail(503,'读音暂时不可用,请一分钟后重试');
  124. try {
  125. const audio=result.audio||await wordAudioSynthesize(word,config);
  126. // Persist the provider result before uploading, so retries can resume without TTS.
  127. const saved=await Psql.query('UPDATE "WordAudioCache" SET audio=$3,"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2 AND "leaseUntil">now() RETURNING "objectId"',[id,token,audio]);
  128. if(!saved.length)fail(409,'读音任务已过期,请重试');
  129. const url=await wordAudioUpload(id,audio,config);
  130. const ready=await Psql.query('UPDATE "WordAudioCache" SET status=\'ready\',url=$3,lease=NULL,"leaseUntil"=NULL,"retryAfter"=NULL,"lastError"=NULL,"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2 RETURNING "objectId"',[id,token,url]);
  131. if(!ready.length)fail(409,'读音任务已更新,请重试');
  132. await Psql.none('UPDATE "WordAudioAttempt" SET status=\'ready\',"updatedAt"=now() WHERE "objectId"=$1',[token]);
  133. return await wordAudioBind(row,url);
  134. } catch(error) {
  135. await Psql.none('UPDATE "WordAudioCache" SET status=\'failed\',lease=NULL,"leaseUntil"=NULL,"retryAfter"=now()+interval \'1 minute\',"lastError"=\'generation_or_storage_failed\',"updatedAt"=now() WHERE "objectId"=$1 AND lease=$2',[id,token]);
  136. await Psql.none('UPDATE "WordAudioAttempt" SET status=\'failed\',"updatedAt"=now() WHERE "objectId"=$1',[token]);
  137. // Transport exceptions may include signed URLs. Only emit our own controlled errors.
  138. fail(502,'读音生成或保存暂时失败,请稍后点击喇叭重试');
  139. }
  140. }