library-picturebook-activity/lesingle-aicreate-client/demo/CreationProgressTracker.js
2026-04-03 20:55:51 +08:00

281 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 创作进度追踪器
*
* 三级通信策略:
* 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)
}
}
}