281 lines
8.2 KiB
JavaScript
281 lines
8.2 KiB
JavaScript
/**
|
||
* 创作进度追踪器
|
||
*
|
||
* 三级通信策略:
|
||
* Level 1: WebSocket (STOMP) — 首选,实时推送,自动重连
|
||
* Level 2: B2 HTTP 轮询 — WebSocket 断连期间临时补位
|
||
* Level 3: Redis 进度数据 — 断连期间进度不丢失,重连后立刻获取最新状态
|
||
*
|
||
* 使用示例:
|
||
* const tracker = new CreationProgressTracker({
|
||
* serverUrl: 'https://your-server.com',
|
||
* orgId: 'ORG001',
|
||
* appKey: 'ORG001',
|
||
* appSecret: 'your-secret',
|
||
* workId: '2034987994917769216',
|
||
* onProgress: (progress, message) => {
|
||
* console.log(`${progress}% - ${message}`)
|
||
* },
|
||
* onComplete: (result) => {
|
||
* // result.status === 'COMPLETED' => result.pageList 包含完整作品
|
||
* // result.status === 'FAILED' => result.failReason 包含失败原因
|
||
* },
|
||
* onError: (error) => {
|
||
* console.error('Tracker error:', error)
|
||
* }
|
||
* })
|
||
* tracker.start()
|
||
*
|
||
* // 用户退出页面时
|
||
* tracker.stop()
|
||
*/
|
||
|
||
import SockJS from 'sockjs-client/dist/sockjs.min.js'
|
||
import { Client } from '@stomp/stompjs'
|
||
import { generateSignHeaders } from './HmacSigner.js'
|
||
|
||
export default class CreationProgressTracker {
|
||
/**
|
||
* @param {Object} options
|
||
* @param {string} options.serverUrl - 服务器地址 (如 https://your-server.com)
|
||
* @param {string} options.orgId - 机构ID
|
||
* @param {string} options.appKey - HMAC 签名 App Key (通常等于 orgId)
|
||
* @param {string} options.appSecret - HMAC 签名密钥
|
||
* @param {string} options.workId - 作品ID
|
||
* @param {Function} options.onProgress - 进度回调 (progress: number, message: string)
|
||
* @param {Function} options.onComplete - 完成回调 (result: CreationResultVO)
|
||
* @param {Function} [options.onError] - 错误回调 (error: Error)
|
||
* @param {number} [options.pollInterval=5000] - B2 轮询间隔 (ms)
|
||
* @param {number} [options.reconnectDelay=3000] - WebSocket 重连延迟 (ms)
|
||
* @param {number} [options.maxPollTimeout=600000] - 最大轮询时间 (ms),默认10分钟
|
||
*/
|
||
constructor(options) {
|
||
this.serverUrl = options.serverUrl
|
||
this.orgId = options.orgId
|
||
this.appKey = options.appKey
|
||
this.appSecret = options.appSecret
|
||
this.workId = options.workId
|
||
this.onProgress = options.onProgress || (() => {})
|
||
this.onComplete = options.onComplete || (() => {})
|
||
this.onError = options.onError || (() => {})
|
||
this.pollInterval = options.pollInterval || 5000
|
||
this.reconnectDelay = options.reconnectDelay || 3000
|
||
this.maxPollTimeout = options.maxPollTimeout || 600000
|
||
|
||
this._stompClient = null
|
||
this._pollTimer = null
|
||
this._wsConnected = false
|
||
this._stopped = false
|
||
this._startTime = 0
|
||
this._lastProgress = -1
|
||
}
|
||
|
||
/**
|
||
* 启动进度追踪
|
||
*/
|
||
start() {
|
||
this._stopped = false
|
||
this._startTime = Date.now()
|
||
|
||
// 尝试 WebSocket 连接
|
||
this._connectWebSocket()
|
||
|
||
// 安全网:5秒内 WS 未连上则启动轮询
|
||
setTimeout(() => {
|
||
if (!this._wsConnected && !this._stopped) {
|
||
this._startPolling()
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
/**
|
||
* 停止追踪(用户离开页面时调用)
|
||
*/
|
||
stop() {
|
||
this._stopped = true
|
||
this._stopPolling()
|
||
this._disconnectWebSocket()
|
||
}
|
||
|
||
// ─── WebSocket (Level 1) ──────────────────────────────────────────
|
||
|
||
_connectWebSocket() {
|
||
if (this._stopped) return
|
||
|
||
try {
|
||
const wsUrl = `${this.serverUrl}/ws?orgId=${this.orgId}`
|
||
|
||
this._stompClient = new Client({
|
||
webSocketFactory: () => new SockJS(wsUrl),
|
||
reconnectDelay: this.reconnectDelay,
|
||
heartbeatIncoming: 10000,
|
||
heartbeatOutgoing: 10000,
|
||
|
||
onConnect: () => {
|
||
this._wsConnected = true
|
||
this._stopPolling() // WS 连上了,停止轮询
|
||
|
||
// 订阅进度 topic
|
||
this._stompClient.subscribe(
|
||
`/topic/progress/${this.workId}`,
|
||
(msg) => {
|
||
try {
|
||
const data = JSON.parse(msg.body)
|
||
this._handleProgressData(data)
|
||
} catch (e) {
|
||
// ignore parse errors
|
||
}
|
||
}
|
||
)
|
||
|
||
// 重连成功后,立即查一次 B2 获取断连期间可能错过的最终状态
|
||
this._pollOnce()
|
||
},
|
||
|
||
onDisconnect: () => {
|
||
this._wsConnected = false
|
||
if (!this._stopped) {
|
||
this._startPolling() // WS 断了,启动轮询补位
|
||
}
|
||
},
|
||
|
||
onStompError: (frame) => {
|
||
this._wsConnected = false
|
||
if (!this._stopped) {
|
||
this._startPolling()
|
||
}
|
||
},
|
||
|
||
onWebSocketError: () => {
|
||
this._wsConnected = false
|
||
if (!this._stopped) {
|
||
this._startPolling()
|
||
}
|
||
}
|
||
})
|
||
|
||
this._stompClient.activate()
|
||
} catch (e) {
|
||
// WebSocket 不可用(如被防火墙阻断),降级为纯轮询
|
||
this._startPolling()
|
||
}
|
||
}
|
||
|
||
_disconnectWebSocket() {
|
||
if (this._stompClient) {
|
||
try {
|
||
this._stompClient.deactivate()
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
this._stompClient = null
|
||
}
|
||
this._wsConnected = false
|
||
}
|
||
|
||
// ─── B2 HTTP 轮询 (Level 2) ───────────────────────────────────────
|
||
|
||
_startPolling() {
|
||
if (this._pollTimer || this._stopped) return
|
||
this._pollTimer = setInterval(() => this._pollOnce(), this.pollInterval)
|
||
}
|
||
|
||
_stopPolling() {
|
||
if (this._pollTimer) {
|
||
clearInterval(this._pollTimer)
|
||
this._pollTimer = null
|
||
}
|
||
}
|
||
|
||
async _pollOnce() {
|
||
if (this._stopped) return
|
||
|
||
// 超时保护
|
||
if (Date.now() - this._startTime > this.maxPollTimeout) {
|
||
this.stop()
|
||
this.onError(new Error('进度查询超时'))
|
||
return
|
||
}
|
||
|
||
try {
|
||
const queryParams = { orgId: this.orgId }
|
||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||
|
||
const res = await fetch(
|
||
`${this.serverUrl}/api/v1/query/work/${this.workId}?orgId=${this.orgId}`,
|
||
{ headers: { ...signHeaders, 'Content-Type': 'application/json' } }
|
||
)
|
||
|
||
if (!res.ok) return
|
||
|
||
const json = await res.json()
|
||
if (json.code !== 200 || !json.data) return
|
||
|
||
const data = json.data
|
||
|
||
// 推送进度
|
||
if (data.progress != null && data.progress !== this._lastProgress) {
|
||
this._handleProgressData({
|
||
progress: data.progress,
|
||
message: data.progressMessage || ''
|
||
})
|
||
}
|
||
|
||
// 检查是否终态
|
||
if (data.status === 'COMPLETED' || data.status === 'FAILED') {
|
||
this.stop()
|
||
this.onComplete(data)
|
||
}
|
||
} catch (e) {
|
||
// 网络错误,不中断轮询
|
||
}
|
||
}
|
||
|
||
// ─── 进度处理 ─────────────────────────────────────────────────────
|
||
|
||
_handleProgressData(data) {
|
||
if (this._stopped) return
|
||
|
||
const progress = data.progress != null ? data.progress : 0
|
||
const message = data.message || ''
|
||
|
||
// 去重:相同进度不重复回调
|
||
if (progress === this._lastProgress) return
|
||
this._lastProgress = progress
|
||
|
||
// 回调
|
||
this.onProgress(progress, message)
|
||
|
||
// 终态检测(WebSocket 推送的 progress=100 或 -1)
|
||
if (progress === 100) {
|
||
// 完成,再查一次 B2 获取完整数据(含 pageList)
|
||
setTimeout(() => this._fetchFinalResult(), 1000)
|
||
} else if (progress === -1) {
|
||
// 失败,查一次 B2 获取 failReason
|
||
setTimeout(() => this._fetchFinalResult(), 1000)
|
||
}
|
||
}
|
||
|
||
async _fetchFinalResult() {
|
||
try {
|
||
const queryParams = { orgId: this.orgId }
|
||
const signHeaders = generateSignHeaders(this.appKey, this.appSecret, queryParams)
|
||
|
||
const res = await fetch(
|
||
`${this.serverUrl}/api/v1/query/work/${this.workId}?orgId=${this.orgId}`,
|
||
{ headers: { ...signHeaders, 'Content-Type': 'application/json' } }
|
||
)
|
||
|
||
if (!res.ok) return
|
||
|
||
const json = await res.json()
|
||
if (json.code === 200 && json.data) {
|
||
this.stop()
|
||
this.onComplete(json.data)
|
||
}
|
||
} catch (e) {
|
||
this.onError(e)
|
||
}
|
||
}
|
||
}
|