library-picturebook-activity/lesingle-aicreate-client/demo/CreationProgressTracker.js

281 lines
8.2 KiB
JavaScript
Raw Permalink Normal View History

2026-04-03 20:55:51 +08:00
/**
* 创作进度追踪器
*
* 三级通信策略
* 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)
}
}
}