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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|