@dofe/infra-shared-services 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/email/dto/email.dto.d.ts +70 -0
- package/dist/email/dto/email.dto.d.ts.map +1 -0
- package/dist/email/dto/email.dto.js +58 -0
- package/dist/email/dto/email.dto.js.map +1 -0
- package/dist/email/email.module.d.ts +3 -0
- package/dist/email/email.module.d.ts.map +1 -0
- package/dist/email/email.module.js +38 -0
- package/dist/email/email.module.js.map +1 -0
- package/dist/email/email.service.d.ts +61 -0
- package/dist/email/email.service.d.ts.map +1 -0
- package/dist/email/email.service.js +191 -0
- package/dist/email/email.service.js.map +1 -0
- package/dist/email/index.d.ts +5 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +26 -0
- package/dist/email/index.js.map +1 -0
- package/dist/file-storage/bucket-resolver.d.ts +174 -0
- package/dist/file-storage/bucket-resolver.d.ts.map +1 -0
- package/dist/file-storage/bucket-resolver.js +270 -0
- package/dist/file-storage/bucket-resolver.js.map +1 -0
- package/dist/file-storage/file-storage.factory.d.ts +183 -0
- package/dist/file-storage/file-storage.factory.d.ts.map +1 -0
- package/dist/file-storage/file-storage.factory.js +300 -0
- package/dist/file-storage/file-storage.factory.js.map +1 -0
- package/dist/file-storage/file-storage.module.d.ts +49 -0
- package/dist/file-storage/file-storage.module.d.ts.map +1 -0
- package/dist/file-storage/file-storage.module.js +74 -0
- package/dist/file-storage/file-storage.module.js.map +1 -0
- package/dist/file-storage/file-storage.service.d.ts +381 -0
- package/dist/file-storage/file-storage.service.d.ts.map +1 -0
- package/dist/file-storage/file-storage.service.js +598 -0
- package/dist/file-storage/file-storage.service.js.map +1 -0
- package/dist/file-storage/index.d.ts +43 -0
- package/dist/file-storage/index.d.ts.map +1 -0
- package/dist/file-storage/index.js +65 -0
- package/dist/file-storage/index.js.map +1 -0
- package/dist/file-storage/types.d.ts +187 -0
- package/dist/file-storage/types.d.ts.map +1 -0
- package/dist/file-storage/types.js +21 -0
- package/dist/file-storage/types.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/ip-geo/continent-mapping.d.ts +31 -0
- package/dist/ip-geo/continent-mapping.d.ts.map +1 -0
- package/dist/ip-geo/continent-mapping.js +246 -0
- package/dist/ip-geo/continent-mapping.js.map +1 -0
- package/dist/ip-geo/index.d.ts +33 -0
- package/dist/ip-geo/index.d.ts.map +1 -0
- package/dist/ip-geo/index.js +41 -0
- package/dist/ip-geo/index.js.map +1 -0
- package/dist/ip-geo/ip-geo.module.d.ts +8 -0
- package/dist/ip-geo/ip-geo.module.d.ts.map +1 -0
- package/dist/ip-geo/ip-geo.module.js +34 -0
- package/dist/ip-geo/ip-geo.module.js.map +1 -0
- package/dist/ip-geo/ip-geo.service.d.ts +43 -0
- package/dist/ip-geo/ip-geo.service.d.ts.map +1 -0
- package/dist/ip-geo/ip-geo.service.js +153 -0
- package/dist/ip-geo/ip-geo.service.js.map +1 -0
- package/dist/ip-info/index.d.ts +7 -0
- package/dist/ip-info/index.d.ts.map +1 -0
- package/dist/ip-info/index.js +13 -0
- package/dist/ip-info/index.js.map +1 -0
- package/dist/ip-info/ip-info.client.d.ts +32 -0
- package/dist/ip-info/ip-info.client.d.ts.map +1 -0
- package/dist/ip-info/ip-info.client.js +73 -0
- package/dist/ip-info/ip-info.client.js.map +1 -0
- package/dist/ip-info/ip-info.module.d.ts +3 -0
- package/dist/ip-info/ip-info.module.d.ts.map +1 -0
- package/dist/ip-info/ip-info.module.js +45 -0
- package/dist/ip-info/ip-info.module.js.map +1 -0
- package/dist/ip-info/ip-info.service.d.ts +50 -0
- package/dist/ip-info/ip-info.service.d.ts.map +1 -0
- package/dist/ip-info/ip-info.service.js +177 -0
- package/dist/ip-info/ip-info.service.js.map +1 -0
- package/dist/ocr/index.d.ts +33 -0
- package/dist/ocr/index.d.ts.map +1 -0
- package/dist/ocr/index.js +53 -0
- package/dist/ocr/index.js.map +1 -0
- package/dist/ocr/ocr.module.d.ts +41 -0
- package/dist/ocr/ocr.module.d.ts.map +1 -0
- package/dist/ocr/ocr.module.js +61 -0
- package/dist/ocr/ocr.module.js.map +1 -0
- package/dist/ocr/ocr.service.d.ts +111 -0
- package/dist/ocr/ocr.service.d.ts.map +1 -0
- package/dist/ocr/ocr.service.js +214 -0
- package/dist/ocr/ocr.service.js.map +1 -0
- package/dist/sms/index.d.ts +5 -0
- package/dist/sms/index.d.ts.map +1 -0
- package/dist/sms/index.js +29 -0
- package/dist/sms/index.js.map +1 -0
- package/dist/sms/sms.factory.d.ts +140 -0
- package/dist/sms/sms.factory.d.ts.map +1 -0
- package/dist/sms/sms.factory.js +276 -0
- package/dist/sms/sms.factory.js.map +1 -0
- package/dist/sms/sms.module.d.ts +3 -0
- package/dist/sms/sms.module.d.ts.map +1 -0
- package/dist/sms/sms.module.js +38 -0
- package/dist/sms/sms.module.js.map +1 -0
- package/dist/sms/sms.service.d.ts +139 -0
- package/dist/sms/sms.service.d.ts.map +1 -0
- package/dist/sms/sms.service.js +278 -0
- package/dist/sms/sms.service.js.map +1 -0
- package/dist/sms/types.d.ts +204 -0
- package/dist/sms/types.d.ts.map +1 -0
- package/dist/sms/types.js +44 -0
- package/dist/sms/types.js.map +1 -0
- package/dist/streaming-asr/index.d.ts +45 -0
- package/dist/streaming-asr/index.d.ts.map +1 -0
- package/dist/streaming-asr/index.js +66 -0
- package/dist/streaming-asr/index.js.map +1 -0
- package/dist/streaming-asr/streaming-asr.module.d.ts +37 -0
- package/dist/streaming-asr/streaming-asr.module.d.ts.map +1 -0
- package/dist/streaming-asr/streaming-asr.module.js +59 -0
- package/dist/streaming-asr/streaming-asr.module.js.map +1 -0
- package/dist/streaming-asr/streaming-asr.service.d.ts +276 -0
- package/dist/streaming-asr/streaming-asr.service.d.ts.map +1 -0
- package/dist/streaming-asr/streaming-asr.service.js +1120 -0
- package/dist/streaming-asr/streaming-asr.service.js.map +1 -0
- package/dist/streaming-asr/types.d.ts +194 -0
- package/dist/streaming-asr/types.d.ts.map +1 -0
- package/dist/streaming-asr/types.js +8 -0
- package/dist/streaming-asr/types.js.map +1 -0
- package/dist/system-health/index.d.ts +7 -0
- package/dist/system-health/index.d.ts.map +1 -0
- package/dist/system-health/index.js +25 -0
- package/dist/system-health/index.js.map +1 -0
- package/dist/system-health/system-health.controller.d.ts +43 -0
- package/dist/system-health/system-health.controller.d.ts.map +1 -0
- package/dist/system-health/system-health.controller.js +86 -0
- package/dist/system-health/system-health.controller.js.map +1 -0
- package/dist/system-health/system-health.module.d.ts +3 -0
- package/dist/system-health/system-health.module.d.ts.map +1 -0
- package/dist/system-health/system-health.module.js +29 -0
- package/dist/system-health/system-health.module.js.map +1 -0
- package/dist/system-health/system-health.service.d.ts +14 -0
- package/dist/system-health/system-health.service.d.ts.map +1 -0
- package/dist/system-health/system-health.service.js +87 -0
- package/dist/system-health/system-health.service.js.map +1 -0
- package/dist/uploader/index.d.ts +3 -0
- package/dist/uploader/index.d.ts.map +1 -0
- package/dist/uploader/index.js +8 -0
- package/dist/uploader/index.js.map +1 -0
- package/dist/uploader/uploader.module.d.ts +3 -0
- package/dist/uploader/uploader.module.d.ts.map +1 -0
- package/dist/uploader/uploader.module.js +25 -0
- package/dist/uploader/uploader.module.js.map +1 -0
- package/dist/uploader/uploader.service.d.ts +86 -0
- package/dist/uploader/uploader.service.d.ts.map +1 -0
- package/dist/uploader/uploader.service.js +188 -0
- package/dist/uploader/uploader.service.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview 流式语音识别服务
|
|
4
|
+
*
|
|
5
|
+
* 本服务提供流式语音识别的核心业务逻辑,包括:
|
|
6
|
+
* - 创建和管理流式识别会话
|
|
7
|
+
* - 音频数据传输
|
|
8
|
+
* - 实时识别结果处理
|
|
9
|
+
* - 与会议记录的集成
|
|
10
|
+
*
|
|
11
|
+
* @module streaming-asr/service
|
|
12
|
+
*/
|
|
13
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
14
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
15
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
16
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
17
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
18
|
+
};
|
|
19
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
20
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
21
|
+
};
|
|
22
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
23
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.StreamingAsrService = void 0;
|
|
30
|
+
const common_1 = require("@nestjs/common");
|
|
31
|
+
const nest_winston_1 = require("nest-winston");
|
|
32
|
+
const winston_1 = require("winston");
|
|
33
|
+
const uuid_1 = require("uuid");
|
|
34
|
+
const events_1 = require("events");
|
|
35
|
+
const config_1 = require("@nestjs/config");
|
|
36
|
+
const jwt_1 = require("@nestjs/jwt");
|
|
37
|
+
const infra_clients_1 = require("@dofe/infra-clients");
|
|
38
|
+
const infra_common_1 = require("@dofe/infra-common");
|
|
39
|
+
const infra_redis_1 = require("@dofe/infra-redis");
|
|
40
|
+
const environment_util_1 = __importDefault(require("@dofe/infra-utils/environment.util"));
|
|
41
|
+
/**
|
|
42
|
+
* 会话超时配置
|
|
43
|
+
*/
|
|
44
|
+
const SESSION_TIMEOUT_CONFIG = {
|
|
45
|
+
/** 会话最大活跃时间(4小时)
|
|
46
|
+
* 会议录制时长限制:超过4小时将自动结束录制
|
|
47
|
+
*/
|
|
48
|
+
maxSessionDuration: 4 * 60 * 60 * 1000,
|
|
49
|
+
/** 会话空闲超时(60分钟)
|
|
50
|
+
* 注意: 用户可能暂停录音较长时间,10分钟过短会导致数据丢失
|
|
51
|
+
* 修改为 60 分钟以保护用户数据
|
|
52
|
+
*/
|
|
53
|
+
idleTimeout: 60 * 60 * 1000,
|
|
54
|
+
/** 清理检查间隔(1分钟) */
|
|
55
|
+
cleanupInterval: 60 * 1000,
|
|
56
|
+
/** 完成会话保留时间(30秒) */
|
|
57
|
+
completedRetention: 30 * 1000,
|
|
58
|
+
/** 时长警告阈值(3.5小时)
|
|
59
|
+
* 当录制时长达到此阈值时,前端会显示警告
|
|
60
|
+
*/
|
|
61
|
+
durationWarningThreshold: 3.5 * 60 * 60 * 1000,
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* 流式语音识别服务
|
|
65
|
+
*
|
|
66
|
+
* @description 提供流式语音识别的业务逻辑处理:
|
|
67
|
+
*
|
|
68
|
+
* 1. **创建会话** - `createSession`
|
|
69
|
+
* 创建流式识别会话,建立 WebSocket 连接
|
|
70
|
+
*
|
|
71
|
+
* 2. **发送音频** - `sendAudio`
|
|
72
|
+
* 发送音频数据到识别服务
|
|
73
|
+
*
|
|
74
|
+
* 3. **完成会话** - `completeSession`
|
|
75
|
+
* 结束识别会话,获取最终结果
|
|
76
|
+
*
|
|
77
|
+
* 4. **查询状态** - `getSessionStatus`
|
|
78
|
+
* 获取会话的实时状态和结果
|
|
79
|
+
*
|
|
80
|
+
* 5. **事件订阅** - `subscribeToSession`
|
|
81
|
+
* 订阅实时识别结果事件
|
|
82
|
+
*
|
|
83
|
+
* @class StreamingAsrService
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* @Injectable()
|
|
88
|
+
* class StreamingAsrController {
|
|
89
|
+
* constructor(private readonly streamingAsr: StreamingAsrService) {}
|
|
90
|
+
*
|
|
91
|
+
* async startSession(dto: CreateStreamingSessionDto) {
|
|
92
|
+
* const result = await this.streamingAsr.createSession(dto);
|
|
93
|
+
* return result;
|
|
94
|
+
* }
|
|
95
|
+
*
|
|
96
|
+
* async sendAudioChunk(connectionId: string, audioData: Buffer) {
|
|
97
|
+
* await this.streamingAsr.sendAudio(connectionId, audioData);
|
|
98
|
+
* }
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
let StreamingAsrService = class StreamingAsrService {
|
|
103
|
+
config;
|
|
104
|
+
jwt;
|
|
105
|
+
redis;
|
|
106
|
+
logger;
|
|
107
|
+
/**
|
|
108
|
+
* 流式识别 Provider
|
|
109
|
+
*/
|
|
110
|
+
provider;
|
|
111
|
+
/**
|
|
112
|
+
* 会话信息存储
|
|
113
|
+
* key: sessionId
|
|
114
|
+
*/
|
|
115
|
+
sessions = new Map();
|
|
116
|
+
/**
|
|
117
|
+
* connectionId -> sessionId 映射
|
|
118
|
+
*/
|
|
119
|
+
connectionToSession = new Map();
|
|
120
|
+
/**
|
|
121
|
+
* 事件发射器(用于 SSE 推送)
|
|
122
|
+
*/
|
|
123
|
+
eventEmitter = new events_1.EventEmitter();
|
|
124
|
+
/**
|
|
125
|
+
* 会话超时清理定时器
|
|
126
|
+
*/
|
|
127
|
+
cleanupTimer = null;
|
|
128
|
+
constructor(config, jwt, redis, logger) {
|
|
129
|
+
this.config = config;
|
|
130
|
+
this.jwt = jwt;
|
|
131
|
+
this.redis = redis;
|
|
132
|
+
this.logger = logger;
|
|
133
|
+
// 初始化 Provider
|
|
134
|
+
this.initProvider();
|
|
135
|
+
// 启动会话清理定时器
|
|
136
|
+
this.startCleanupTimer();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 模块销毁时清理资源
|
|
140
|
+
*/
|
|
141
|
+
async onModuleDestroy() {
|
|
142
|
+
this.stopCleanupTimer();
|
|
143
|
+
await this.cleanupAllSessions();
|
|
144
|
+
if (environment_util_1.default.isProduction()) {
|
|
145
|
+
this.logger.info('StreamingAsrService module destroyed');
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.logger.debug('StreamingAsrService module destroyed');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 初始化流式识别 Provider
|
|
153
|
+
*/
|
|
154
|
+
initProvider() {
|
|
155
|
+
const openspeechConfig = (0, infra_common_1.getKeysConfig)()?.openspeech;
|
|
156
|
+
const tosConfig = openspeechConfig?.tos;
|
|
157
|
+
if (!tosConfig || !tosConfig.sauc) {
|
|
158
|
+
this.logger.warn('Volcengine OpenSpeech SAUC config (tos.sauc) not found, streaming ASR will not be available');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const config = {
|
|
162
|
+
appId: tosConfig.appId,
|
|
163
|
+
appAccessToken: tosConfig.appAccessToken,
|
|
164
|
+
uid: tosConfig.uid,
|
|
165
|
+
endpoint: tosConfig.sauc.endpoint,
|
|
166
|
+
resourceId: tosConfig.sauc.resourceId,
|
|
167
|
+
appAccessSecret: tosConfig.appAccessSecret,
|
|
168
|
+
accessKey: tosConfig.accessKey,
|
|
169
|
+
secretKey: tosConfig.secretKey,
|
|
170
|
+
};
|
|
171
|
+
this.provider = new infra_clients_1.VolcengineStreamingAsrProvider(this.logger, config);
|
|
172
|
+
if (environment_util_1.default.isProduction()) {
|
|
173
|
+
this.logger.info('StreamingAsrService module initialized');
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.logger.debug('StreamingAsrService module initialized');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 检查 Provider 是否可用
|
|
181
|
+
*/
|
|
182
|
+
ensureProviderAvailable() {
|
|
183
|
+
if (!this.provider) {
|
|
184
|
+
throw new Error('Streaming ASR provider is not available. Please check configuration.');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 创建流式识别会话
|
|
189
|
+
*
|
|
190
|
+
* @param {CreateStreamingSessionDto} dto - 创建会话参数
|
|
191
|
+
* @returns {Promise<StreamingSessionResult>} 会话创建结果
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* const session = await streamingAsrService.createSession({
|
|
196
|
+
* userId: 'user-uuid',
|
|
197
|
+
* meetingRecordId: 'meeting-uuid', // 可选
|
|
198
|
+
* audioFormat: 'pcm',
|
|
199
|
+
* });
|
|
200
|
+
* console.log('Session ID:', session.sessionId);
|
|
201
|
+
* console.log('Connection ID:', session.connectionId);
|
|
202
|
+
* ```
|
|
203
|
+
*/
|
|
204
|
+
async createSession(dto) {
|
|
205
|
+
this.ensureProviderAvailable();
|
|
206
|
+
const sessionId = (0, uuid_1.v4)();
|
|
207
|
+
// 构建回调
|
|
208
|
+
const callbacks = {
|
|
209
|
+
onConnected: () => {
|
|
210
|
+
this.handleConnected(sessionId);
|
|
211
|
+
},
|
|
212
|
+
onResult: async (result) => {
|
|
213
|
+
await this.handleResult(sessionId, result);
|
|
214
|
+
},
|
|
215
|
+
onError: (error) => {
|
|
216
|
+
this.handleError(sessionId, error);
|
|
217
|
+
},
|
|
218
|
+
onDisconnected: () => {
|
|
219
|
+
this.handleDisconnected(sessionId);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
// 建立连接
|
|
223
|
+
const connectionId = await this.provider.connect({
|
|
224
|
+
sessionId,
|
|
225
|
+
audioFormat: dto.audioFormat || 'pcm',
|
|
226
|
+
sampleRate: dto.sampleRate || 16000,
|
|
227
|
+
channels: dto.channels || 1,
|
|
228
|
+
enableSpeakerInfo: dto.enableSpeakerInfo !== false,
|
|
229
|
+
corpus: dto.corpus,
|
|
230
|
+
}, callbacks);
|
|
231
|
+
// 创建会话信息
|
|
232
|
+
const now = new Date();
|
|
233
|
+
const sessionInfo = {
|
|
234
|
+
sessionId,
|
|
235
|
+
connectionId,
|
|
236
|
+
status: 'connected',
|
|
237
|
+
meetingRecordId: dto.meetingRecordId,
|
|
238
|
+
userId: dto.userId,
|
|
239
|
+
transcript: '',
|
|
240
|
+
utterances: [],
|
|
241
|
+
audioDuration: 0,
|
|
242
|
+
createdAt: now,
|
|
243
|
+
lastActivityAt: now,
|
|
244
|
+
};
|
|
245
|
+
// 如果启用音频保存,初始化缓冲区
|
|
246
|
+
if (dto.saveAudio) {
|
|
247
|
+
sessionInfo.audioBuffer = {
|
|
248
|
+
chunks: [],
|
|
249
|
+
totalSize: 0,
|
|
250
|
+
startTime: now,
|
|
251
|
+
config: {
|
|
252
|
+
enabled: true,
|
|
253
|
+
format: dto.audioFormat || 'pcm',
|
|
254
|
+
sampleRate: dto.sampleRate || 16000,
|
|
255
|
+
channels: dto.channels || 1,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
this.sessions.set(sessionId, sessionInfo);
|
|
260
|
+
this.connectionToSession.set(connectionId, sessionId);
|
|
261
|
+
// 如果关联了会议记录,更新会议状态
|
|
262
|
+
if (dto.meetingRecordId) {
|
|
263
|
+
try {
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Empty catch - meeting status update failed, but session continues
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.logger.info('Streaming ASR session created', {
|
|
270
|
+
sessionId,
|
|
271
|
+
connectionId,
|
|
272
|
+
meetingRecordId: dto.meetingRecordId,
|
|
273
|
+
});
|
|
274
|
+
// 生成长期有效的 session token (4小时)
|
|
275
|
+
const sessionToken = await this.generateSessionToken(sessionId, dto.userId);
|
|
276
|
+
// 存储 session token 到会话信息中
|
|
277
|
+
sessionInfo.sessionToken = sessionToken;
|
|
278
|
+
return {
|
|
279
|
+
sessionId,
|
|
280
|
+
connectionId,
|
|
281
|
+
status: 'connected',
|
|
282
|
+
meetingRecordId: dto.meetingRecordId,
|
|
283
|
+
sessionToken, // 返回 session token
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 发送音频数据
|
|
288
|
+
*
|
|
289
|
+
* @param {string} sessionIdOrConnectionId - 会话 ID 或连接 ID
|
|
290
|
+
* @param {Buffer} audioData - 音频数据
|
|
291
|
+
* @param {boolean} [isLast=false] - 是否为最后一帧
|
|
292
|
+
*
|
|
293
|
+
* @description
|
|
294
|
+
* 此方法同时支持传入 sessionId 或 connectionId(向后兼容):
|
|
295
|
+
* - 优先作为 sessionId 查找(API 设计的主要方式,路径参数为 sessionId)
|
|
296
|
+
* - 如果找不到,再尝试作为 connectionId 查找(向后兼容)
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```typescript
|
|
300
|
+
* // 使用 sessionId 发送音频数据(推荐,符合 API 设计)
|
|
301
|
+
* await streamingAsrService.sendAudio(sessionId, audioBuffer);
|
|
302
|
+
*
|
|
303
|
+
* // 使用 connectionId 发送音频数据(向后兼容)
|
|
304
|
+
* await streamingAsrService.sendAudio(connectionId, audioBuffer);
|
|
305
|
+
*
|
|
306
|
+
* // 发送最后一帧
|
|
307
|
+
* await streamingAsrService.sendAudio(sessionId, lastBuffer, true);
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
async sendAudio(sessionIdOrConnectionId, audioData, isLast = false) {
|
|
311
|
+
this.ensureProviderAvailable();
|
|
312
|
+
let sessionInfo;
|
|
313
|
+
let connectionId;
|
|
314
|
+
// 优先作为 sessionId 查找(API 设计的主要方式)
|
|
315
|
+
sessionInfo = this.sessions.get(sessionIdOrConnectionId);
|
|
316
|
+
if (sessionInfo) {
|
|
317
|
+
connectionId = sessionInfo.connectionId;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// 如果找不到,尝试作为 connectionId 查找(向后兼容)
|
|
321
|
+
const sessionId = this.connectionToSession.get(sessionIdOrConnectionId);
|
|
322
|
+
if (sessionId) {
|
|
323
|
+
sessionInfo = this.sessions.get(sessionId);
|
|
324
|
+
if (sessionInfo) {
|
|
325
|
+
connectionId = sessionInfo.connectionId;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (!sessionInfo || !connectionId) {
|
|
330
|
+
this.logger.warn('Failed to find session for sendAudio', {
|
|
331
|
+
sessionIdOrConnectionId,
|
|
332
|
+
availableSessions: Array.from(this.sessions.keys()),
|
|
333
|
+
availableConnections: Array.from(this.connectionToSession.keys()),
|
|
334
|
+
});
|
|
335
|
+
throw new common_1.NotFoundException(`Session or connection not found: ${sessionIdOrConnectionId}`);
|
|
336
|
+
}
|
|
337
|
+
// 检查 Provider 连接状态,如果 disconnected 则尝试恢复
|
|
338
|
+
const providerStatus = this.provider?.getConnectionStatus(connectionId);
|
|
339
|
+
if (providerStatus === 'disconnected') {
|
|
340
|
+
// Provider 报告 disconnected,但会话信息存在
|
|
341
|
+
// 尝试通过 sendAudio 触发 Provider 的重连机制
|
|
342
|
+
// Provider 的 sendAudio 方法会自动处理 disconnected 状态的重连
|
|
343
|
+
this.logger.info('Provider connection disconnected, attempting recovery via sendAudio', {
|
|
344
|
+
sessionId: sessionInfo.sessionId,
|
|
345
|
+
connectionId,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
await this.provider.sendAudio(connectionId, audioData, isLast);
|
|
349
|
+
// 更新最后活动时间
|
|
350
|
+
sessionInfo.lastActivityAt = new Date();
|
|
351
|
+
// ⚠️ P2: 更新 Redis 中的会话活动时间(用于服务重启后恢复)
|
|
352
|
+
await this.saveSessionToRedis(sessionInfo).catch((error) => {
|
|
353
|
+
// Redis 保存失败不影响主流程
|
|
354
|
+
this.logger.warn('Failed to update session activity in Redis', {
|
|
355
|
+
sessionId: sessionInfo.sessionId,
|
|
356
|
+
error: error.message,
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
// 估算音频时长(假设 16000Hz, 16bit, mono)
|
|
360
|
+
// audioData.length / (16000 * 2) * 1000 = ms
|
|
361
|
+
const estimatedDuration = (audioData.length / 32000) * 1000;
|
|
362
|
+
sessionInfo.audioDuration += estimatedDuration;
|
|
363
|
+
// 如果启用了音频保存,缓冲音频数据
|
|
364
|
+
if (sessionInfo.audioBuffer) {
|
|
365
|
+
sessionInfo.audioBuffer.chunks.push(audioData);
|
|
366
|
+
sessionInfo.audioBuffer.totalSize += audioData.length;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 完成流式识别会话
|
|
371
|
+
*
|
|
372
|
+
* @param {CompleteStreamingSessionDto} dto - 完成会话参数
|
|
373
|
+
* @returns {Promise<CompleteStreamingSessionResult>} 完成结果
|
|
374
|
+
*/
|
|
375
|
+
async completeSession(dto) {
|
|
376
|
+
const sessionInfo = this.sessions.get(dto.sessionId);
|
|
377
|
+
if (!sessionInfo) {
|
|
378
|
+
throw new common_1.NotFoundException(`Session not found: ${dto.sessionId}`);
|
|
379
|
+
}
|
|
380
|
+
// 获取最终结果
|
|
381
|
+
const providerResult = this.provider.getTranscript(sessionInfo.connectionId);
|
|
382
|
+
if (providerResult) {
|
|
383
|
+
sessionInfo.transcript = providerResult.transcript;
|
|
384
|
+
sessionInfo.utterances = providerResult.utterances;
|
|
385
|
+
}
|
|
386
|
+
// 关闭连接
|
|
387
|
+
await this.provider.disconnect(sessionInfo.connectionId);
|
|
388
|
+
// 更新会话状态
|
|
389
|
+
sessionInfo.status = 'completed';
|
|
390
|
+
// 如果需要保存到会议记录
|
|
391
|
+
if (dto.saveToMeeting !== false && sessionInfo.meetingRecordId) {
|
|
392
|
+
try {
|
|
393
|
+
this.logger.info('Transcript saved to meeting record', {
|
|
394
|
+
sessionId: dto.sessionId,
|
|
395
|
+
meetingRecordId: sessionInfo.meetingRecordId,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
this.logger.error('Failed to save transcript to meeting', {
|
|
400
|
+
sessionId: dto.sessionId,
|
|
401
|
+
meetingRecordId: sessionInfo.meetingRecordId,
|
|
402
|
+
error: error.message,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// 发送完成事件
|
|
407
|
+
this.emitEvent(dto.sessionId, {
|
|
408
|
+
type: 'completed',
|
|
409
|
+
sessionId: dto.sessionId,
|
|
410
|
+
data: {
|
|
411
|
+
sessionId: dto.sessionId,
|
|
412
|
+
connectionId: sessionInfo.connectionId,
|
|
413
|
+
text: sessionInfo.transcript,
|
|
414
|
+
isFinal: true,
|
|
415
|
+
utterances: sessionInfo.utterances,
|
|
416
|
+
},
|
|
417
|
+
timestamp: Date.now(),
|
|
418
|
+
});
|
|
419
|
+
// ⚠️ P2: 清理 Redis 中的会话数据(会话已完成,不再需要持久化)
|
|
420
|
+
try {
|
|
421
|
+
await this.redis.deleteData('streamingAsrSession', dto.sessionId);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
this.logger.warn('Failed to delete completed session from Redis', {
|
|
425
|
+
sessionId: dto.sessionId,
|
|
426
|
+
error: error.message,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
// 清理会话(延迟,允许客户端获取最终结果)
|
|
430
|
+
setTimeout(() => {
|
|
431
|
+
this.cleanupSession(dto.sessionId);
|
|
432
|
+
}, 30000); // 30 秒后清理
|
|
433
|
+
// 合并音频缓冲区
|
|
434
|
+
let audioBuffer;
|
|
435
|
+
let audioFormat;
|
|
436
|
+
if (sessionInfo.audioBuffer && sessionInfo.audioBuffer.chunks.length > 0) {
|
|
437
|
+
audioBuffer = Buffer.concat(sessionInfo.audioBuffer.chunks);
|
|
438
|
+
audioFormat = {
|
|
439
|
+
format: sessionInfo.audioBuffer.config.format,
|
|
440
|
+
sampleRate: sessionInfo.audioBuffer.config.sampleRate,
|
|
441
|
+
channels: sessionInfo.audioBuffer.config.channels,
|
|
442
|
+
};
|
|
443
|
+
this.logger.info('Audio buffer merged', {
|
|
444
|
+
sessionId: dto.sessionId,
|
|
445
|
+
totalSize: audioBuffer.length,
|
|
446
|
+
chunksCount: sessionInfo.audioBuffer.chunks.length,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
this.logger.info('Streaming ASR session completed', {
|
|
450
|
+
sessionId: dto.sessionId,
|
|
451
|
+
transcriptLength: sessionInfo.transcript.length,
|
|
452
|
+
utterancesCount: sessionInfo.utterances.length,
|
|
453
|
+
audioDuration: sessionInfo.audioDuration,
|
|
454
|
+
hasAudioBuffer: !!audioBuffer,
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
sessionId: dto.sessionId,
|
|
458
|
+
finalTranscript: sessionInfo.transcript,
|
|
459
|
+
finalUtterances: sessionInfo.utterances,
|
|
460
|
+
audioDuration: Math.round(sessionInfo.audioDuration / 1000), // 转换为秒
|
|
461
|
+
meetingRecordId: sessionInfo.meetingRecordId,
|
|
462
|
+
audioBuffer,
|
|
463
|
+
audioFormat,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 更新会话活动时间(心跳)
|
|
468
|
+
*
|
|
469
|
+
* @param {string} sessionId - 会话 ID
|
|
470
|
+
* @returns {Promise<{ success: boolean; lastActivityAt: Date }>} 更新结果
|
|
471
|
+
*/
|
|
472
|
+
async updateSessionActivity(sessionId) {
|
|
473
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
474
|
+
if (!sessionInfo) {
|
|
475
|
+
throw new common_1.NotFoundException(`Session not found: ${sessionId}`);
|
|
476
|
+
}
|
|
477
|
+
// 更新最后活动时间
|
|
478
|
+
sessionInfo.lastActivityAt = new Date();
|
|
479
|
+
// ⚠️ P2: 更新 Redis 中的会话活动时间(用于服务重启后恢复)
|
|
480
|
+
await this.saveSessionToRedis(sessionInfo).catch((error) => {
|
|
481
|
+
// Redis 保存失败不影响主流程
|
|
482
|
+
this.logger.warn('Failed to update session activity in Redis', {
|
|
483
|
+
sessionId,
|
|
484
|
+
error: error.message,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
this.logger.debug('Session activity updated', {
|
|
488
|
+
sessionId,
|
|
489
|
+
lastActivityAt: sessionInfo.lastActivityAt,
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
success: true,
|
|
493
|
+
lastActivityAt: sessionInfo.lastActivityAt,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 获取会话状态
|
|
498
|
+
*
|
|
499
|
+
* @param {string} sessionId - 会话 ID
|
|
500
|
+
* @returns {SessionStatusResult} 会话状态
|
|
501
|
+
*/
|
|
502
|
+
async getSessionStatus(sessionId) {
|
|
503
|
+
let sessionInfo = this.sessions.get(sessionId);
|
|
504
|
+
// ⚠️ P2: 如果内存中没有会话,尝试从 Redis 恢复
|
|
505
|
+
if (!sessionInfo) {
|
|
506
|
+
const restoredSession = await this.restoreSessionFromRedis(sessionId);
|
|
507
|
+
if (restoredSession) {
|
|
508
|
+
sessionInfo = restoredSession;
|
|
509
|
+
// 恢复会话到内存
|
|
510
|
+
this.sessions.set(sessionId, sessionInfo);
|
|
511
|
+
this.connectionToSession.set(sessionInfo.connectionId, sessionId);
|
|
512
|
+
this.logger.info('Session restored from Redis in getSessionStatus', {
|
|
513
|
+
sessionId,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (!sessionInfo) {
|
|
518
|
+
throw new common_1.NotFoundException(`Session not found: ${sessionId}`);
|
|
519
|
+
}
|
|
520
|
+
// 获取 Provider 最新状态
|
|
521
|
+
const providerStatus = this.provider?.getConnectionStatus(sessionInfo.connectionId);
|
|
522
|
+
// ⚠️ 重要:不要直接覆盖状态,只在状态确实变化时才更新
|
|
523
|
+
// 这样可以避免 WebSocket 短暂断开时,状态被错误地永久设置为 disconnected
|
|
524
|
+
if (providerStatus) {
|
|
525
|
+
// 只有在以下情况才更新状态:
|
|
526
|
+
// 1. Provider 状态是 connected/streaming,且当前状态不是这些(允许恢复)
|
|
527
|
+
// 2. Provider 状态是 completed/error,且当前状态不是这些(最终状态)
|
|
528
|
+
// 3. 不要将 connected/streaming 覆盖为 disconnected(可能是临时断开)
|
|
529
|
+
const isActiveStatus = providerStatus === 'connected' || providerStatus === 'streaming';
|
|
530
|
+
const isFinalStatus = providerStatus === 'completed' || providerStatus === 'error';
|
|
531
|
+
const currentIsActive = sessionInfo.status === 'connected' ||
|
|
532
|
+
sessionInfo.status === 'streaming';
|
|
533
|
+
if ((isActiveStatus && !currentIsActive) ||
|
|
534
|
+
(isFinalStatus && sessionInfo.status !== providerStatus)) {
|
|
535
|
+
// 允许从 disconnected 恢复到 connected/streaming
|
|
536
|
+
// 或更新为最终状态
|
|
537
|
+
sessionInfo.status = providerStatus;
|
|
538
|
+
}
|
|
539
|
+
else if (providerStatus === 'disconnected' && currentIsActive) {
|
|
540
|
+
// Provider 返回 disconnected,但当前状态是 active
|
|
541
|
+
// 这可能是临时断开,不要覆盖状态,保持当前 active 状态
|
|
542
|
+
this.logger.warn('Provider reports disconnected but session is active, keeping active status', {
|
|
543
|
+
sessionId,
|
|
544
|
+
currentStatus: sessionInfo.status,
|
|
545
|
+
providerStatus,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// 获取最新转写结果
|
|
550
|
+
const providerResult = this.provider?.getTranscript(sessionInfo.connectionId);
|
|
551
|
+
if (providerResult) {
|
|
552
|
+
sessionInfo.transcript = providerResult.transcript;
|
|
553
|
+
sessionInfo.utterances = providerResult.utterances;
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
sessionId,
|
|
557
|
+
connectionId: sessionInfo.connectionId,
|
|
558
|
+
status: sessionInfo.status,
|
|
559
|
+
transcript: sessionInfo.transcript,
|
|
560
|
+
utterances: sessionInfo.utterances,
|
|
561
|
+
recognizedDuration: Math.round(sessionInfo.audioDuration / 1000),
|
|
562
|
+
meetingRecordId: sessionInfo.meetingRecordId,
|
|
563
|
+
error: sessionInfo.error,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* 获取会话的 sessionToken
|
|
568
|
+
* 如果不存在则重新生成
|
|
569
|
+
*
|
|
570
|
+
* @param {string} sessionId - 会话 ID
|
|
571
|
+
* @returns {Promise<string>} sessionToken
|
|
572
|
+
*/
|
|
573
|
+
async getSessionToken(sessionId) {
|
|
574
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
575
|
+
if (!sessionInfo) {
|
|
576
|
+
throw new common_1.NotFoundException(`Session not found: ${sessionId}`);
|
|
577
|
+
}
|
|
578
|
+
// 如果已有 sessionToken,直接返回
|
|
579
|
+
if (sessionInfo.sessionToken) {
|
|
580
|
+
return sessionInfo.sessionToken;
|
|
581
|
+
}
|
|
582
|
+
// 否则重新生成
|
|
583
|
+
const sessionToken = await this.generateSessionToken(sessionId, sessionInfo.userId);
|
|
584
|
+
sessionInfo.sessionToken = sessionToken;
|
|
585
|
+
return sessionToken;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* 取消/断开会话
|
|
589
|
+
*
|
|
590
|
+
* @param {string} sessionId - 会话 ID
|
|
591
|
+
*/
|
|
592
|
+
async cancelSession(sessionId) {
|
|
593
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
594
|
+
if (!sessionInfo) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
await this.provider?.disconnect(sessionInfo.connectionId);
|
|
598
|
+
sessionInfo.status = 'disconnected';
|
|
599
|
+
// 发送断开事件
|
|
600
|
+
this.emitEvent(sessionId, {
|
|
601
|
+
type: 'disconnected',
|
|
602
|
+
sessionId,
|
|
603
|
+
timestamp: Date.now(),
|
|
604
|
+
});
|
|
605
|
+
this.cleanupSession(sessionId);
|
|
606
|
+
this.logger.info('Streaming ASR session cancelled', { sessionId });
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* 订阅会话事件(用于 SSE)
|
|
610
|
+
*
|
|
611
|
+
* @param {string} sessionId - 会话 ID
|
|
612
|
+
* @param {function} callback - 事件回调
|
|
613
|
+
* @returns {function} 取消订阅函数
|
|
614
|
+
*/
|
|
615
|
+
subscribeToSession(sessionId, callback) {
|
|
616
|
+
const eventName = `session:${sessionId}`;
|
|
617
|
+
this.eventEmitter.on(eventName, callback);
|
|
618
|
+
return () => {
|
|
619
|
+
this.eventEmitter.off(eventName, callback);
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* 获取活跃会话数
|
|
624
|
+
*/
|
|
625
|
+
getActiveSessionCount() {
|
|
626
|
+
return this.sessions.size;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* 根据 connectionId 获取 sessionId
|
|
630
|
+
*/
|
|
631
|
+
getSessionIdByConnectionId(connectionId) {
|
|
632
|
+
return this.connectionToSession.get(connectionId);
|
|
633
|
+
}
|
|
634
|
+
// =========================================================================
|
|
635
|
+
// 私有方法
|
|
636
|
+
// =========================================================================
|
|
637
|
+
/**
|
|
638
|
+
* 处理连接建立事件
|
|
639
|
+
*/
|
|
640
|
+
handleConnected(sessionId) {
|
|
641
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
642
|
+
if (sessionInfo) {
|
|
643
|
+
sessionInfo.status = 'connected';
|
|
644
|
+
}
|
|
645
|
+
this.emitEvent(sessionId, {
|
|
646
|
+
type: 'connected',
|
|
647
|
+
sessionId,
|
|
648
|
+
data: sessionInfo
|
|
649
|
+
? {
|
|
650
|
+
sessionId,
|
|
651
|
+
connectionId: sessionInfo.connectionId,
|
|
652
|
+
text: sessionInfo.transcript || '',
|
|
653
|
+
isFinal: false,
|
|
654
|
+
utterances: sessionInfo.utterances,
|
|
655
|
+
}
|
|
656
|
+
: undefined,
|
|
657
|
+
timestamp: Date.now(),
|
|
658
|
+
});
|
|
659
|
+
this.logger.info('Streaming ASR connected', { sessionId });
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 处理识别结果事件
|
|
663
|
+
*/
|
|
664
|
+
async handleResult(sessionId, result) {
|
|
665
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
666
|
+
if (!sessionInfo) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// 更新会话数据
|
|
670
|
+
if (result.text) {
|
|
671
|
+
sessionInfo.transcript = result.text;
|
|
672
|
+
}
|
|
673
|
+
if (result.utterances && result.utterances.length > 0) {
|
|
674
|
+
sessionInfo.utterances = result.utterances;
|
|
675
|
+
}
|
|
676
|
+
sessionInfo.status = result.isFinal ? 'completed' : 'streaming';
|
|
677
|
+
sessionInfo.lastActivityAt = new Date();
|
|
678
|
+
// ⚠️ P2: 更新 Redis 中的转写数据(用于服务重启后恢复)
|
|
679
|
+
await this.saveSessionToRedis(sessionInfo).catch((error) => {
|
|
680
|
+
// Redis 保存失败不影响主流程
|
|
681
|
+
this.logger.warn('Failed to save session to Redis', {
|
|
682
|
+
sessionId,
|
|
683
|
+
error: error.message,
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
// 发送实时更新事件
|
|
687
|
+
this.emitEvent(sessionId, {
|
|
688
|
+
type: 'transcript',
|
|
689
|
+
sessionId,
|
|
690
|
+
data: {
|
|
691
|
+
sessionId,
|
|
692
|
+
connectionId: sessionInfo.connectionId,
|
|
693
|
+
text: result.text,
|
|
694
|
+
isFinal: result.isFinal,
|
|
695
|
+
utterances: result.utterances,
|
|
696
|
+
sequence: result.sequence,
|
|
697
|
+
},
|
|
698
|
+
timestamp: Date.now(),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* 处理错误事件
|
|
703
|
+
*/
|
|
704
|
+
handleError(sessionId, error) {
|
|
705
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
706
|
+
if (sessionInfo) {
|
|
707
|
+
sessionInfo.status = 'error';
|
|
708
|
+
sessionInfo.error = error.message;
|
|
709
|
+
}
|
|
710
|
+
// 发送错误事件,确保包含所有必需字段以通过前端验证
|
|
711
|
+
this.emitEvent(sessionId, {
|
|
712
|
+
type: 'error',
|
|
713
|
+
sessionId,
|
|
714
|
+
data: {
|
|
715
|
+
sessionId,
|
|
716
|
+
connectionId: sessionInfo?.connectionId || '',
|
|
717
|
+
text: sessionInfo?.transcript || '',
|
|
718
|
+
isFinal: false,
|
|
719
|
+
error: error.message,
|
|
720
|
+
utterances: sessionInfo?.utterances || [],
|
|
721
|
+
},
|
|
722
|
+
timestamp: Date.now(),
|
|
723
|
+
});
|
|
724
|
+
this.logger.error('Streaming ASR error', {
|
|
725
|
+
sessionId,
|
|
726
|
+
connectionId: sessionInfo?.connectionId,
|
|
727
|
+
error: error.message,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* 处理断开连接事件
|
|
732
|
+
*/
|
|
733
|
+
handleDisconnected(sessionId) {
|
|
734
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
735
|
+
if (sessionInfo && sessionInfo.status !== 'completed') {
|
|
736
|
+
sessionInfo.status = 'disconnected';
|
|
737
|
+
}
|
|
738
|
+
this.emitEvent(sessionId, {
|
|
739
|
+
type: 'disconnected',
|
|
740
|
+
sessionId,
|
|
741
|
+
data: sessionInfo
|
|
742
|
+
? {
|
|
743
|
+
sessionId,
|
|
744
|
+
connectionId: sessionInfo.connectionId,
|
|
745
|
+
text: sessionInfo.transcript || '',
|
|
746
|
+
isFinal: false,
|
|
747
|
+
utterances: sessionInfo.utterances,
|
|
748
|
+
}
|
|
749
|
+
: undefined,
|
|
750
|
+
timestamp: Date.now(),
|
|
751
|
+
});
|
|
752
|
+
this.logger.info('Streaming ASR disconnected', { sessionId });
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* 发送事件
|
|
756
|
+
*/
|
|
757
|
+
emitEvent(sessionId, event) {
|
|
758
|
+
const eventName = `session:${sessionId}`;
|
|
759
|
+
this.eventEmitter.emit(eventName, event);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* 清理会话
|
|
763
|
+
*/
|
|
764
|
+
cleanupSession(sessionId) {
|
|
765
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
766
|
+
if (sessionInfo) {
|
|
767
|
+
this.connectionToSession.delete(sessionInfo.connectionId);
|
|
768
|
+
}
|
|
769
|
+
this.sessions.delete(sessionId);
|
|
770
|
+
this.eventEmitter.removeAllListeners(`session:${sessionId}`);
|
|
771
|
+
this.logger.info('Session cleaned up', { sessionId });
|
|
772
|
+
}
|
|
773
|
+
// =========================================================================
|
|
774
|
+
// 会话超时清理相关方法
|
|
775
|
+
// =========================================================================
|
|
776
|
+
/**
|
|
777
|
+
* 启动会话清理定时器
|
|
778
|
+
*/
|
|
779
|
+
startCleanupTimer() {
|
|
780
|
+
if (this.cleanupTimer) {
|
|
781
|
+
clearInterval(this.cleanupTimer);
|
|
782
|
+
}
|
|
783
|
+
this.cleanupTimer = setInterval(() => {
|
|
784
|
+
this.checkAndCleanupSessions();
|
|
785
|
+
}, SESSION_TIMEOUT_CONFIG.cleanupInterval);
|
|
786
|
+
if (environment_util_1.default.isProduction()) {
|
|
787
|
+
this.logger.info('Session cleanup timer started', {
|
|
788
|
+
intervalMs: SESSION_TIMEOUT_CONFIG.cleanupInterval,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
this.logger.debug('Session cleanup timer started', {
|
|
793
|
+
intervalMs: SESSION_TIMEOUT_CONFIG.cleanupInterval,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* 停止会话清理定时器
|
|
799
|
+
*/
|
|
800
|
+
stopCleanupTimer() {
|
|
801
|
+
if (this.cleanupTimer) {
|
|
802
|
+
clearInterval(this.cleanupTimer);
|
|
803
|
+
this.cleanupTimer = null;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* 检查并清理超时会话
|
|
808
|
+
*/
|
|
809
|
+
checkAndCleanupSessions() {
|
|
810
|
+
const now = Date.now();
|
|
811
|
+
const sessionsToCleanup = [];
|
|
812
|
+
const sessionsToAutoComplete = [];
|
|
813
|
+
for (const [sessionId, sessionInfo] of this.sessions) {
|
|
814
|
+
const createdAtMs = sessionInfo.createdAt.getTime();
|
|
815
|
+
const lastActivityAtMs = sessionInfo.lastActivityAt.getTime();
|
|
816
|
+
// 检查是否超过最大会话时长
|
|
817
|
+
if (now - createdAtMs > SESSION_TIMEOUT_CONFIG.maxSessionDuration) {
|
|
818
|
+
// 如果有 meetingRecordId,自动完成会议而不是清理
|
|
819
|
+
if (sessionInfo.meetingRecordId) {
|
|
820
|
+
this.logger.warn('Session exceeded max duration, auto-completing meeting', {
|
|
821
|
+
sessionId,
|
|
822
|
+
meetingId: sessionInfo.meetingRecordId,
|
|
823
|
+
durationMs: now - createdAtMs,
|
|
824
|
+
});
|
|
825
|
+
sessionsToAutoComplete.push({
|
|
826
|
+
sessionId,
|
|
827
|
+
meetingId: sessionInfo.meetingRecordId,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
this.logger.warn('Session exceeded max duration, cleaning up', {
|
|
832
|
+
sessionId,
|
|
833
|
+
durationMs: now - createdAtMs,
|
|
834
|
+
});
|
|
835
|
+
sessionsToCleanup.push(sessionId);
|
|
836
|
+
}
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
// 检查是否空闲超时
|
|
840
|
+
if (sessionInfo.status !== 'completed' &&
|
|
841
|
+
now - lastActivityAtMs > SESSION_TIMEOUT_CONFIG.idleTimeout) {
|
|
842
|
+
this.logger.warn('Session idle timeout, cleaning up', {
|
|
843
|
+
sessionId,
|
|
844
|
+
idleMs: now - lastActivityAtMs,
|
|
845
|
+
});
|
|
846
|
+
sessionsToCleanup.push(sessionId);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
// 检查已完成会话是否超过保留时间
|
|
850
|
+
if (sessionInfo.status === 'completed' &&
|
|
851
|
+
now - lastActivityAtMs > SESSION_TIMEOUT_CONFIG.completedRetention) {
|
|
852
|
+
sessionsToCleanup.push(sessionId);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// 自动完成超时的会议会话
|
|
856
|
+
for (const { sessionId, meetingId } of sessionsToAutoComplete) {
|
|
857
|
+
this.autoCompleteMeeting(sessionId, meetingId).catch((error) => {
|
|
858
|
+
this.logger.error('Failed to auto-complete meeting', {
|
|
859
|
+
sessionId,
|
|
860
|
+
meetingId,
|
|
861
|
+
error: error.message,
|
|
862
|
+
});
|
|
863
|
+
// 如果自动完成失败,回退到清理
|
|
864
|
+
sessionsToCleanup.push(sessionId);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
// 执行清理
|
|
868
|
+
for (const sessionId of sessionsToCleanup) {
|
|
869
|
+
// ⚠️ P2: forceCleanupSession 现在是 async
|
|
870
|
+
this.forceCleanupSession(sessionId).catch((error) => {
|
|
871
|
+
this.logger.warn('Failed to cleanup session', {
|
|
872
|
+
sessionId,
|
|
873
|
+
error: error.message,
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
if (sessionsToCleanup.length > 0 || sessionsToAutoComplete.length > 0) {
|
|
878
|
+
this.logger.info('Session cleanup completed', {
|
|
879
|
+
cleanedCount: sessionsToCleanup.length,
|
|
880
|
+
autoCompletedCount: sessionsToAutoComplete.length,
|
|
881
|
+
remainingCount: this.sessions.size,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* 自动完成会议(超时时调用)
|
|
887
|
+
* @private
|
|
888
|
+
*/
|
|
889
|
+
async autoCompleteMeeting(sessionId, meetingId) {
|
|
890
|
+
try {
|
|
891
|
+
this.logger.info('Auto-completing meeting due to duration limit', {
|
|
892
|
+
sessionId,
|
|
893
|
+
meetingId,
|
|
894
|
+
});
|
|
895
|
+
// 发送超时事件通知前端
|
|
896
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
897
|
+
const eventData = {
|
|
898
|
+
sessionId,
|
|
899
|
+
connectionId: sessionInfo?.connectionId || '',
|
|
900
|
+
text: sessionInfo?.transcript || '',
|
|
901
|
+
isFinal: false,
|
|
902
|
+
utterances: sessionInfo?.utterances || [],
|
|
903
|
+
message: 'Recording duration has reached the 4-hour limit. Meeting will be automatically completed.',
|
|
904
|
+
};
|
|
905
|
+
// 发送超时事件通知前端
|
|
906
|
+
// 注意:'duration-exceeded' 已在 types.ts 中添加到 StreamingAsrEventType
|
|
907
|
+
// 使用双重类型断言以解决 TypeScript 类型缓存问题
|
|
908
|
+
const event = {
|
|
909
|
+
type: 'duration-exceeded',
|
|
910
|
+
sessionId,
|
|
911
|
+
data: eventData,
|
|
912
|
+
timestamp: Date.now(),
|
|
913
|
+
};
|
|
914
|
+
this.emitEvent(sessionId, event);
|
|
915
|
+
this.logger.info('Meeting auto-completed successfully', {
|
|
916
|
+
sessionId,
|
|
917
|
+
meetingId,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
this.logger.error('Failed to auto-complete meeting', {
|
|
922
|
+
sessionId,
|
|
923
|
+
meetingId,
|
|
924
|
+
error: error.message,
|
|
925
|
+
});
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* 保存会话数据到 Redis(用于服务重启后恢复)
|
|
931
|
+
*
|
|
932
|
+
* ⚠️ P2: Redis 持久化 - 保存转写数据,避免服务重启导致数据丢失
|
|
933
|
+
*/
|
|
934
|
+
async saveSessionToRedis(sessionInfo) {
|
|
935
|
+
try {
|
|
936
|
+
const sessionData = {
|
|
937
|
+
sessionId: sessionInfo.sessionId,
|
|
938
|
+
connectionId: sessionInfo.connectionId,
|
|
939
|
+
status: sessionInfo.status,
|
|
940
|
+
meetingRecordId: sessionInfo.meetingRecordId,
|
|
941
|
+
userId: sessionInfo.userId,
|
|
942
|
+
transcript: sessionInfo.transcript,
|
|
943
|
+
utterances: sessionInfo.utterances,
|
|
944
|
+
audioDuration: sessionInfo.audioDuration,
|
|
945
|
+
createdAt: sessionInfo.createdAt.toISOString(),
|
|
946
|
+
lastActivityAt: sessionInfo.lastActivityAt.toISOString(),
|
|
947
|
+
sessionToken: sessionInfo.sessionToken,
|
|
948
|
+
};
|
|
949
|
+
await this.redis.saveData('streamingAsrSession', sessionInfo.sessionId, sessionData, 7200);
|
|
950
|
+
this.logger.debug('Session saved to Redis', {
|
|
951
|
+
sessionId: sessionInfo.sessionId,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
// Redis 保存失败不影响主流程,只记录警告
|
|
956
|
+
this.logger.warn('Failed to save session to Redis', {
|
|
957
|
+
sessionId: sessionInfo.sessionId,
|
|
958
|
+
error: error.message,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* 从 Redis 恢复会话数据(用于服务重启后恢复)
|
|
964
|
+
*
|
|
965
|
+
* ⚠️ P2: Redis 持久化 - 服务重启时恢复会话数据
|
|
966
|
+
*/
|
|
967
|
+
async restoreSessionFromRedis(sessionId) {
|
|
968
|
+
try {
|
|
969
|
+
const sessionData = await this.redis.getData('streamingAsrSession', sessionId);
|
|
970
|
+
if (!sessionData) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
const sessionInfo = {
|
|
974
|
+
sessionId: sessionData.sessionId,
|
|
975
|
+
connectionId: sessionData.connectionId,
|
|
976
|
+
status: sessionData.status,
|
|
977
|
+
meetingRecordId: sessionData.meetingRecordId,
|
|
978
|
+
userId: sessionData.userId,
|
|
979
|
+
transcript: sessionData.transcript || '',
|
|
980
|
+
utterances: sessionData.utterances || [],
|
|
981
|
+
audioDuration: sessionData.audioDuration || 0,
|
|
982
|
+
createdAt: new Date(sessionData.createdAt),
|
|
983
|
+
lastActivityAt: new Date(sessionData.lastActivityAt),
|
|
984
|
+
sessionToken: sessionData.sessionToken,
|
|
985
|
+
};
|
|
986
|
+
this.logger.info('Session restored from Redis', {
|
|
987
|
+
sessionId,
|
|
988
|
+
status: sessionInfo.status,
|
|
989
|
+
});
|
|
990
|
+
return sessionInfo;
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
this.logger.warn('Failed to restore session from Redis', {
|
|
994
|
+
sessionId,
|
|
995
|
+
error: error.message,
|
|
996
|
+
});
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* 强制清理会话(用于超时清理)
|
|
1002
|
+
*/
|
|
1003
|
+
async forceCleanupSession(sessionId) {
|
|
1004
|
+
const sessionInfo = this.sessions.get(sessionId);
|
|
1005
|
+
// ⚠️ P2: 清理 Redis 中的会话数据
|
|
1006
|
+
try {
|
|
1007
|
+
await this.redis.deleteData('streamingAsrSession', sessionId);
|
|
1008
|
+
}
|
|
1009
|
+
catch (error) {
|
|
1010
|
+
this.logger.warn('Failed to delete session from Redis', {
|
|
1011
|
+
sessionId,
|
|
1012
|
+
error: error.message,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
if (!sessionInfo)
|
|
1016
|
+
return;
|
|
1017
|
+
// 如果会话仍在进行中,先断开连接
|
|
1018
|
+
if (sessionInfo.status !== 'completed' &&
|
|
1019
|
+
sessionInfo.status !== 'disconnected') {
|
|
1020
|
+
try {
|
|
1021
|
+
await this.provider?.disconnect(sessionInfo.connectionId);
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
this.logger.warn('Failed to disconnect during force cleanup', {
|
|
1025
|
+
sessionId,
|
|
1026
|
+
error: error.message,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
// 发送断开事件
|
|
1030
|
+
this.emitEvent(sessionId, {
|
|
1031
|
+
type: 'disconnected',
|
|
1032
|
+
sessionId,
|
|
1033
|
+
data: {
|
|
1034
|
+
sessionId,
|
|
1035
|
+
connectionId: sessionInfo.connectionId,
|
|
1036
|
+
text: sessionInfo.transcript || '',
|
|
1037
|
+
isFinal: false,
|
|
1038
|
+
error: 'Session timed out',
|
|
1039
|
+
utterances: sessionInfo.utterances,
|
|
1040
|
+
},
|
|
1041
|
+
timestamp: Date.now(),
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
this.cleanupSession(sessionId);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* 清理所有会话
|
|
1048
|
+
*/
|
|
1049
|
+
async cleanupAllSessions() {
|
|
1050
|
+
const sessionIds = Array.from(this.sessions.keys());
|
|
1051
|
+
for (const sessionId of sessionIds) {
|
|
1052
|
+
await this.forceCleanupSession(sessionId);
|
|
1053
|
+
}
|
|
1054
|
+
if (environment_util_1.default.isProduction()) {
|
|
1055
|
+
this.logger.info('StreamingAsrService all sessions cleaned up');
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
this.logger.debug('StreamingAsrService all sessions cleaned up');
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* 获取会话统计信息
|
|
1063
|
+
*/
|
|
1064
|
+
getSessionStats() {
|
|
1065
|
+
let streamingCount = 0;
|
|
1066
|
+
let completedCount = 0;
|
|
1067
|
+
let errorCount = 0;
|
|
1068
|
+
for (const sessionInfo of this.sessions.values()) {
|
|
1069
|
+
switch (sessionInfo.status) {
|
|
1070
|
+
case 'streaming':
|
|
1071
|
+
streamingCount++;
|
|
1072
|
+
break;
|
|
1073
|
+
case 'completed':
|
|
1074
|
+
completedCount++;
|
|
1075
|
+
break;
|
|
1076
|
+
case 'error':
|
|
1077
|
+
errorCount++;
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return {
|
|
1082
|
+
activeCount: this.sessions.size,
|
|
1083
|
+
streamingCount,
|
|
1084
|
+
completedCount,
|
|
1085
|
+
errorCount,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* 生成 session token
|
|
1090
|
+
*
|
|
1091
|
+
* @description
|
|
1092
|
+
* 生成长期有效的 session token (4小时),用于音频发送认证
|
|
1093
|
+
* 解决 JWT token refresh 导致的音频数据丢失问题
|
|
1094
|
+
*
|
|
1095
|
+
* @param sessionId - Session ID
|
|
1096
|
+
* @param userId - User ID
|
|
1097
|
+
* @returns Session token
|
|
1098
|
+
*/
|
|
1099
|
+
async generateSessionToken(sessionId, userId) {
|
|
1100
|
+
const jwtConfig = this.config.getOrThrow('jwt');
|
|
1101
|
+
return await this.jwt.signAsync({
|
|
1102
|
+
sessionId,
|
|
1103
|
+
userId,
|
|
1104
|
+
type: 'streaming-asr-session',
|
|
1105
|
+
}, {
|
|
1106
|
+
secret: jwtConfig.secret,
|
|
1107
|
+
expiresIn: '4h', // 4小时,比 session 最大时长(2小时)长
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
exports.StreamingAsrService = StreamingAsrService;
|
|
1112
|
+
exports.StreamingAsrService = StreamingAsrService = __decorate([
|
|
1113
|
+
(0, common_1.Injectable)(),
|
|
1114
|
+
__param(3, (0, common_1.Inject)(nest_winston_1.WINSTON_MODULE_PROVIDER)),
|
|
1115
|
+
__metadata("design:paramtypes", [config_1.ConfigService,
|
|
1116
|
+
jwt_1.JwtService,
|
|
1117
|
+
infra_redis_1.RedisService,
|
|
1118
|
+
winston_1.Logger])
|
|
1119
|
+
], StreamingAsrService);
|
|
1120
|
+
//# sourceMappingURL=streaming-asr.service.js.map
|