cardbuddy 0.20.0 → 0.21.0
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/README.md +1 -1
- package/dist/cli.js +14 -0
- package/dist/preview-editor/assets/index-C8VLzU18.css +1 -0
- package/dist/preview-editor/assets/index-CpDojF__.js +383 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/standalone.js +49 -0
- package/dist/vendored/card-editor/runtime/card-runtime.js +1 -1
- package/dist/vendored/mcp/client.mjs +22 -3
- package/dist/vendored/server/core/ai-chat.js +92 -12
- package/dist/vendored/server/core/cardExport.js +831 -0
- package/dist/vendored/server/core/cardImportMeta.js +6 -1
- package/dist/vendored/server/core/cardPublishVersions.js +91 -0
- package/dist/vendored/server/core/cardSave.js +143 -6
- package/dist/vendored/server/core/cardSync.js +48 -6
- package/dist/vendored/server/core/index.js +11 -0
- package/dist/vendored/skills/finmall-card-sync/SKILL.md +46 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +303 -8
- package/dist/vendored/skills/finmall-session-navigator/scripts/finmall_login.mjs +42 -3
- package/dist/vendored/skills/mbank-design-gen/tools/screenshot.mjs +10 -1
- package/package.json +1 -1
- package/dist/preview-editor/assets/index-08GutEI0.css +0 -1
- package/dist/preview-editor/assets/index-Dx5hfJ7f.js +0 -358
|
@@ -26,8 +26,9 @@ export const META_SCHEMA_VERSION = 1
|
|
|
26
26
|
* @param {string} o.cardCode 英文卡 id(目录名)
|
|
27
27
|
* @param {string} [o.cardName] 卡片标题
|
|
28
28
|
* @param {object} [o.files] { schema, data, cover } 字节数
|
|
29
|
+
* @param {object} [o.remote] 远端列表元数据快照(picUrl / cardClassId / applyScene)
|
|
29
30
|
*/
|
|
30
|
-
export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode, cardName, files }) {
|
|
31
|
+
export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode, cardName, files, remote }) {
|
|
31
32
|
return {
|
|
32
33
|
schemaVersion: META_SCHEMA_VERSION,
|
|
33
34
|
source: 'IDC10012',
|
|
@@ -39,6 +40,10 @@ export function buildImportMeta({ baseUrl, appId, workspaceId, cardId, cardCode,
|
|
|
39
40
|
cardCode: cardCode || '',
|
|
40
41
|
cardName: cardName || '',
|
|
41
42
|
files: files || null,
|
|
43
|
+
// 远端卡片列表(IDC10001)才有、本地 DSL 产品数据不持久化的元数据:picUrl / cardClassId / applyScene。
|
|
44
|
+
// 导出包必须带上**真值**:导入侧(ImportCardAction.java:61-64)无条件按 cardPO.picUrl 末段
|
|
45
|
+
// 推文件名去读卡目录根的封面,picUrl 为空会退化成目录路径、直接抛异常。真值只在同步时可获得。
|
|
46
|
+
remote: remote || null,
|
|
42
47
|
syncedAt: new Date().toISOString(),
|
|
43
48
|
tool: 'finmall-card-sync',
|
|
44
49
|
dirty: false, // 本地编辑后偏离导入镜像为 true(重置为导入镜像后回 false)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { findCardDir } from './cardSidecar.js'
|
|
3
|
+
import { readImportMeta } from './cardImportMeta.js'
|
|
4
|
+
import { log } from './logger.js'
|
|
5
|
+
import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
6
|
+
|
|
7
|
+
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 调试面板「导入信息」tab「发布版本记录」弹框的远端数据源(只读):
|
|
11
|
+
* GET /__card-publish-versions/:cardCode?pageNo=&pageSize=
|
|
12
|
+
*
|
|
13
|
+
* 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10004,取该卡发布版本记录分页(新→旧),
|
|
14
|
+
* 每条含版本号 / 平台(iOS·Android·HarmonyOS 子集)/ 发布状态 / 发布时间。
|
|
15
|
+
* 触发时机由前端控制(打开弹框 / 滚动到底懒加载下一页),不自动轮询——与「编辑记录」tab
|
|
16
|
+
* 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
|
|
17
|
+
*
|
|
18
|
+
* 安全闸(与 /__card-history / /__card-version 同源):仅「已同步导入卡」(有
|
|
19
|
+
* .import-meta.json 侧车且含 cardId)可查;本地自建卡 / 未同步卡返回 409 local_card_no_source。
|
|
20
|
+
*
|
|
21
|
+
* @param {{ root: string }} opts
|
|
22
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
23
|
+
*/
|
|
24
|
+
export function createCardPublishVersionsMiddleware({ root }) {
|
|
25
|
+
return function cardPublishVersionsMiddleware(req, res, next) {
|
|
26
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
27
|
+
const m = pathname.match(/^\/__card-publish-versions\/([^/]+)$/)
|
|
28
|
+
if (!m) return next()
|
|
29
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') return next()
|
|
30
|
+
|
|
31
|
+
const id = decodeURIComponent(m[1])
|
|
32
|
+
const dir = findCardDir(root, { code: id })
|
|
33
|
+
const meta = dir ? readImportMeta(dir) : null
|
|
34
|
+
if (!meta || !meta.cardId) {
|
|
35
|
+
res.statusCode = 409
|
|
36
|
+
res.setHeader('Content-Type', 'application/json')
|
|
37
|
+
return res.end(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
ok: false,
|
|
40
|
+
error: 'local_card_no_source',
|
|
41
|
+
message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法查看发布版本记录。',
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const qs = new URL(req.url || '/', 'http://localhost').searchParams
|
|
47
|
+
const pageNo = Math.max(1, parseInt(qs.get('pageNo') || '1', 10) || 1)
|
|
48
|
+
const pageSize = Math.min(100, Math.max(1, parseInt(qs.get('pageSize') || '10', 10) || 10))
|
|
49
|
+
|
|
50
|
+
;(async () => {
|
|
51
|
+
try {
|
|
52
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
53
|
+
const { fetchCardPublishVersions } = await import(pathToFileURL(skillPath).href)
|
|
54
|
+
const r = await fetchCardPublishVersions(meta.cardId, { pageNo, pageSize })
|
|
55
|
+
log.ok('查询远端发布版本记录', {
|
|
56
|
+
cardCode: id,
|
|
57
|
+
cardId: String(meta.cardId),
|
|
58
|
+
pageNo,
|
|
59
|
+
count: r.list.length,
|
|
60
|
+
})
|
|
61
|
+
res.statusCode = 200
|
|
62
|
+
res.setHeader('Content-Type', 'application/json')
|
|
63
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
64
|
+
res.end(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
ok: true,
|
|
67
|
+
cardId: String(meta.cardId),
|
|
68
|
+
cardCode: id,
|
|
69
|
+
list: r.list,
|
|
70
|
+
maxVersion: r.maxVersion,
|
|
71
|
+
totalNums: r.totalNums,
|
|
72
|
+
totalPages: r.totalPages,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// 区分「凭据缺失」与「真正的查询错误」:前者让前端提示登录,而非裸抛 ENOENT。
|
|
77
|
+
const isNoCred =
|
|
78
|
+
e && /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(String(e && e.message))
|
|
79
|
+
res.statusCode = isNoCred ? 200 : 500
|
|
80
|
+
res.setHeader('Content-Type', 'application/json')
|
|
81
|
+
res.end(
|
|
82
|
+
JSON.stringify(
|
|
83
|
+
isNoCred
|
|
84
|
+
? { error: 'need_credentials', needCredentials: true, message: String(e && e.message) }
|
|
85
|
+
: { error: 'publish versions failed', message: String(e && e.message) },
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
})()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -9,17 +9,115 @@ import { checkPushAllowed } from './cardListCache.js'
|
|
|
9
9
|
import { assertRemoteSource, findCardDir } from './cardSidecar.js'
|
|
10
10
|
import { log } from './logger.js'
|
|
11
11
|
import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
12
|
+
import { generateCardCover } from './screenshot.js'
|
|
13
|
+
import { serialize } from './browser.js'
|
|
12
14
|
|
|
13
15
|
/** 远端保存脚本相对 skills/ 的路径(与「同步」共用 finmall-card-sync) */
|
|
14
16
|
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* 推送成功后「截图 + 上传为远端预览图」的整段预算。
|
|
20
|
+
*
|
|
21
|
+
* 为什么要预算:这一步串了无头渲染(首次拉起 Chrome 可达数秒)与一次跨网 HTTPS 上传,
|
|
22
|
+
* 两步各有 30s 级超时。不设总闸的话,最坏情况会把 /__save 的响应拖到 1 分钟以上——
|
|
23
|
+
* 用户看到的是「点了推送一直在转圈」,而卡片内容其实早就写成功了。到点即放弃并
|
|
24
|
+
* 在响应里带 preview.error 说明,不影响推送本身的成功结论。
|
|
25
|
+
*/
|
|
26
|
+
const PUSH_PREVIEW_TIMEOUT_MS = Number(process.env.CARDBUDDY_PUSH_PREVIEW_TIMEOUT_MS || 45000)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 推送成功后自动更新远端预览图的总开关(默认开)。
|
|
30
|
+
* 需要临时关闭时设 `CARDBUDDY_PUSH_PREVIEW=0`(例如远端图片库维护中,避免每条推送都多等一次失败)。
|
|
31
|
+
*/
|
|
32
|
+
const PUSH_PREVIEW_ENABLED = process.env.CARDBUDDY_PUSH_PREVIEW !== '0'
|
|
33
|
+
|
|
34
|
+
/** 带总时限的 Promise 竞速(超时不取消底层任务,只放弃等待) */
|
|
35
|
+
function withDeadline(promise, ms, label) {
|
|
36
|
+
let timer
|
|
37
|
+
return Promise.race([
|
|
38
|
+
promise,
|
|
39
|
+
new Promise((_, reject) => {
|
|
40
|
+
timer = setTimeout(() => reject(new Error(`${label}超时(>${ms}ms),已放弃`)), ms)
|
|
41
|
+
}),
|
|
42
|
+
]).finally(() => clearTimeout(timer))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 推送成功后把「当前卡片」截图上传为远端预览图(IDC10014.do)。
|
|
47
|
+
*
|
|
48
|
+
* 为什么放在服务端而不是前端:①截图走本地无头渲染(screenshot.js 单例 Chrome),
|
|
49
|
+
* 与「预览图」按钮同一条链路,产出透明底、与卡片实际渲染尺寸 1:1 的 PNG;
|
|
50
|
+
* ②上传必须带 FinMall token,只有已登录的后台会话拿得到,前端不接触凭据。
|
|
51
|
+
*
|
|
52
|
+
* 三条纪律:
|
|
53
|
+
* - **best-effort**:任何失败都返回结构化结果,绝不向上抛——预览图是推送的附加产物,
|
|
54
|
+
* 不能让它把「卡片已保存到远端」这个既成事实变成一次报错;
|
|
55
|
+
* - **不写本地预览图**:只上传,不覆盖 src/cards/<id>/<id>.png。写盘会触发 watcher 的
|
|
56
|
+
* cards-changed,而截图 + 上传耗时可能超出前端 2.5s 的自保存抑制窗口,会误弹
|
|
57
|
+
* 「卡片已在编辑器外被修改」——本地封面仍由「预览图」按钮显式生成(单一触发点);
|
|
58
|
+
* - **渲染基于磁盘态**:调用方(前端「推送」)需先确保未保存编辑已落盘,否则截到的是
|
|
59
|
+
* 上一版布局(见 App.vue saveToRemote 的前置 saveCard)。
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} opts.cardCode 卡片目录名(= 英文卡 id,截图入口用它定位卡片)
|
|
63
|
+
* @param {string|number} opts.cardId 数字型远端 cardId(= schema.pageId,IDC10014.do 主键)
|
|
64
|
+
* @param {number} [opts.width] 卡片设计宽度(缺省 375,取自 schema.view.width)
|
|
65
|
+
* @param {unknown[]} [opts.components] 卡片组件数组(用于识别空白卡:空数组时不截图)
|
|
66
|
+
* @param {(p: {cardId: string, pngBase64: string}) => Promise<object>} opts.upload
|
|
67
|
+
* 上传实现(注入 skill 的 uploadCardPreview)
|
|
68
|
+
* @param {(id: string, o?: object) => Promise<object>} [opts.shoot]
|
|
69
|
+
* 截图实现(默认 generateCardCover;单测可注入)
|
|
70
|
+
* @returns {Promise<{ ok: boolean, width?: number|null, height?: number|null, bytes?: number, url?: string, error?: string, message?: string }>}
|
|
71
|
+
*/
|
|
72
|
+
export async function uploadPreviewAfterPush({
|
|
73
|
+
cardCode,
|
|
74
|
+
cardId,
|
|
75
|
+
width,
|
|
76
|
+
components,
|
|
77
|
+
upload,
|
|
78
|
+
shoot = generateCardCover,
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (!cardCode) return { ok: false, error: 'no_card_code', message: '未定位到卡片目录,跳过预览图上传' }
|
|
81
|
+
if (!cardId) return { ok: false, error: 'no_card_id', message: '缺少数字型 cardId,跳过预览图上传' }
|
|
82
|
+
if (typeof upload !== 'function') {
|
|
83
|
+
return { ok: false, error: 'upload_unavailable', message: '当前 skill 未提供 uploadCardPreview,跳过预览图上传' }
|
|
84
|
+
}
|
|
85
|
+
// 空白卡(组件数组为空):无头渲染只会得到一张纯透明图,传上去等于把列表缩略图刷没。
|
|
86
|
+
// 与 /__card-image 的 422 判定同源(那里同样按「数组存在且为空」认定空白卡)。
|
|
87
|
+
if (Array.isArray(components) && !components.length) {
|
|
88
|
+
return { ok: false, error: 'empty-card', message: '空白卡(无组件)没有可预览内容,跳过预览图上传' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 与 /__card-image、/__screenshot 共用同一浏览器单例 + 串行队列,避免同时渲多张打爆内存
|
|
92
|
+
const shot = await serialize(() => shoot(cardCode, { width: Number(width) > 0 ? Number(width) : 375 }))
|
|
93
|
+
if (!shot || shot.tooBusy) {
|
|
94
|
+
return { ok: false, error: 'too-busy', message: '截图队列已满,跳过预览图上传' }
|
|
95
|
+
}
|
|
96
|
+
if (!shot.buffer) {
|
|
97
|
+
return { ok: false, error: shot.error || 'screenshot-failed', message: shot.message || '截图失败' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const uploaded = await upload({ cardId: String(cardId), pngBase64: shot.buffer.toString('base64') })
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
width: shot.width,
|
|
104
|
+
height: shot.height,
|
|
105
|
+
bytes: shot.buffer.length,
|
|
106
|
+
url: (uploaded && uploaded.url) || '',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
16
110
|
/**
|
|
17
111
|
* 远端保存(写回 FinMall)服务端处理。
|
|
18
112
|
* 前端「保存」按钮把当前预览的 schema(含实时数据绑定)POST 过来,
|
|
19
113
|
* 本中间件校验 pageId 后,经已登录浏览器中转调用 IDC10010.do 写回远端,
|
|
20
114
|
* 与「同步」共用 finmall-card-sync 的 saveCardToRemote(带会话自愈)。
|
|
115
|
+
*
|
|
116
|
+
* @param {{ root: string, previewAfterPush?: typeof uploadPreviewAfterPush }} opts
|
|
117
|
+
* - previewAfterPush:推送成功后的「截图 + 上传预览图」实现,默认即上面的
|
|
118
|
+
* uploadPreviewAfterPush;单测注入替身以便在无 Chrome / 无 FinMall 会话的环境断言接线。
|
|
21
119
|
*/
|
|
22
|
-
export function createSaveMiddleware({ root }) {
|
|
120
|
+
export function createSaveMiddleware({ root, previewAfterPush = uploadPreviewAfterPush }) {
|
|
23
121
|
return async function saveMiddleware(req, res, next) {
|
|
24
122
|
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
25
123
|
const method = (req.method || 'GET').toUpperCase()
|
|
@@ -102,17 +200,18 @@ export function createSaveMiddleware({ root }) {
|
|
|
102
200
|
pathToFileURL(resolveSkillPathOrProject(root || process.cwd(), SYNC_SCRIPT_REL)).href
|
|
103
201
|
)
|
|
104
202
|
const { response, verified } = await mod.saveCardToRemote(schema, { skeletonScreen })
|
|
203
|
+
// 卡片目录反查只做一次:既供导出元数据落盘,也供随后的预览图截图定位(目录名 = 英文卡 id)。
|
|
204
|
+
const cardDir = root ? findCardDir(root, { code, pageId: schema.pageId }) : null
|
|
105
205
|
// 导出可追溯(Phase E):把远端保存的读回校验结果写进导入元数据侧车。
|
|
106
206
|
// 注意:目录按 cardCode 命名,必须按 pageId 反查到正确的 src/cards/<cardCode>/。
|
|
107
207
|
if (root) {
|
|
108
208
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await recordExport(dir, verified)
|
|
209
|
+
if (cardDir) {
|
|
210
|
+
await recordExport(cardDir, verified)
|
|
112
211
|
// 推送成功即与远端对齐:清除「偏离导入镜像」标记(来源 tab 据此提示)。
|
|
113
212
|
// 注:本地磁盘(含编辑)仍可能 ≠ 当初导入镜像,此处语义转为「已与远端同步」,
|
|
114
213
|
// 符合用户预期——推送到远端后不应再被标为「偏离」。
|
|
115
|
-
await markDirty(
|
|
214
|
+
await markDirty(cardDir, false)
|
|
116
215
|
} else console.warn(`[__save] 未找到 pageId=${schema.pageId} 对应的本地卡片目录,跳过导出元数据`)
|
|
117
216
|
} catch (e) {
|
|
118
217
|
log.warn('写导出元数据失败(不影响保存结果)', { error: e && e.message })
|
|
@@ -126,9 +225,47 @@ export function createSaveMiddleware({ root }) {
|
|
|
126
225
|
if (!verified.fingerprintMatched && verified.diff && verified.diff.length) {
|
|
127
226
|
log.warn('指纹不匹配(落库内容与本地不一致)', { diff: verified.diff.slice(0, 3) })
|
|
128
227
|
}
|
|
228
|
+
|
|
229
|
+
// 推送成功后自动更新远端预览图:本地无头渲染当前卡片 → IDC10014.do 上传。
|
|
230
|
+
// 全程 best-effort:失败只记日志 + 在响应里带 preview.error,推送本身的成功结论不受影响。
|
|
231
|
+
let preview = null
|
|
232
|
+
if (PUSH_PREVIEW_ENABLED) {
|
|
233
|
+
try {
|
|
234
|
+
preview = await withDeadline(
|
|
235
|
+
previewAfterPush({
|
|
236
|
+
cardCode: cardDir ? path.basename(cardDir) : '',
|
|
237
|
+
cardId: String(schema.pageId),
|
|
238
|
+
width: Number((schema.view && schema.view.width) || 0),
|
|
239
|
+
components: schema.view && schema.view.components,
|
|
240
|
+
upload: typeof mod.uploadCardPreview === 'function' ? mod.uploadCardPreview : null,
|
|
241
|
+
}),
|
|
242
|
+
PUSH_PREVIEW_TIMEOUT_MS,
|
|
243
|
+
'推送后预览图上传',
|
|
244
|
+
)
|
|
245
|
+
} catch (e) {
|
|
246
|
+
preview = { ok: false, error: 'preview_failed', message: String(e && e.message) }
|
|
247
|
+
}
|
|
248
|
+
if (preview && preview.ok) {
|
|
249
|
+
log.ok('推送后预览图已更新', {
|
|
250
|
+
cardId: String(schema.pageId),
|
|
251
|
+
width: preview.width,
|
|
252
|
+
height: preview.height,
|
|
253
|
+
bytes: preview.bytes,
|
|
254
|
+
})
|
|
255
|
+
} else {
|
|
256
|
+
log.warn('推送成功但预览图未更新(不影响卡片内容)', {
|
|
257
|
+
cardId: String(schema.pageId),
|
|
258
|
+
error: (preview && preview.error) || 'unknown',
|
|
259
|
+
message: (preview && preview.message) || '',
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
129
264
|
res.statusCode = 200
|
|
130
265
|
res.setHeader('Content-Type', 'application/json')
|
|
131
|
-
return res.end(
|
|
266
|
+
return res.end(
|
|
267
|
+
JSON.stringify({ ok: true, code, cardId: String(schema.pageId), response, verified, preview }),
|
|
268
|
+
)
|
|
132
269
|
} catch (e) {
|
|
133
270
|
log.error('远端保存失败', e)
|
|
134
271
|
res.statusCode = 502
|
|
@@ -13,6 +13,19 @@ const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
|
13
13
|
/** 卡片 id 合法形态(与 normalizeCard / 新建卡同源正则) */
|
|
14
14
|
const CARD_CODE_RE = /^[A-Za-z0-9_-]+$/
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 同步任务硬超时(看门狗)。
|
|
18
|
+
*
|
|
19
|
+
* 为什么必须有:同步中间件是单飞制(`activeSyncJob()` 未终态时,之后每次 POST /__sync 都拿
|
|
20
|
+
* 409「已有同步任务正在进行」)。而同步链路上游仍有「await 一个永不 settle 的 Promise」的
|
|
21
|
+
* 可能(page.evaluate 无默认超时、页面内 fetch 挂起、CDP 连接半死等)——一旦某个任务挂在
|
|
22
|
+
* 这种 await 上,`finally { job.finished = true }` 永远不执行,单飞锁被永久占用,
|
|
23
|
+
* 用户侧表现就是「点了远端同步、服务端再也不响应」。
|
|
24
|
+
* 看门狗到点把任务判超时收尾(置 cancelRequested 让 syncCards 在下一张卡片前退出、
|
|
25
|
+
* 直接标 finished 释放锁),保证任何单次同步都不会无限期占用服务。
|
|
26
|
+
*/
|
|
27
|
+
const SYNC_JOB_TIMEOUT_MS = Number(process.env.CARDBUDDY_SYNC_JOB_TIMEOUT_MS || 10 * 60 * 1000)
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* 同步任务表(进程内单例)。
|
|
18
31
|
* 任务制模型:POST /__sync 立即返回 jobId,syncCards 在后台跑;
|
|
@@ -249,12 +262,37 @@ export function createSyncMiddleware({ root }) {
|
|
|
249
262
|
id: `sj-${Date.now().toString(36)}-${syncJobSeq}`,
|
|
250
263
|
createdAt: Date.now(),
|
|
251
264
|
cancelRequested: false,
|
|
265
|
+
timedOut: false,
|
|
252
266
|
finished: false,
|
|
253
267
|
progress: { done: 0, total: cardCode ? 1 : cardCodes ? cardCodes.length : 0, currentId: '', currentTitle: '' },
|
|
254
268
|
result: null,
|
|
255
269
|
}
|
|
256
270
|
rememberSyncJob(job)
|
|
257
271
|
|
|
272
|
+
// 看门狗:硬超时兜底。同步链路里仍有「await 一个永不 settle 的 Promise」的可能
|
|
273
|
+
// (page.evaluate 无默认超时 / 页面内 fetch 挂起 / CDP 半死),一旦挂住,下面 IIFE 的
|
|
274
|
+
// finally 永远不执行 → 单飞锁被永久占用 → 之后每次同步都 409,用户侧就是「服务端僵死」。
|
|
275
|
+
// 到点后代它收尾:置 cancelRequested 让 syncCards 在下一张卡片前退出,并直接标 finished
|
|
276
|
+
// 释放锁;后台任务若稍后真的返回,也以看门狗的结论为准(不再回滚成「成功」误导用户)。
|
|
277
|
+
const watchdog = setTimeout(() => {
|
|
278
|
+
if (job.finished) return
|
|
279
|
+
job.timedOut = true
|
|
280
|
+
job.cancelRequested = true
|
|
281
|
+
job.finished = true
|
|
282
|
+
job.result = {
|
|
283
|
+
success: false,
|
|
284
|
+
timedOut: true,
|
|
285
|
+
error: `同步任务超过 ${Math.round(SYNC_JOB_TIMEOUT_MS / 1000)} 秒未完成,已自动收尾(上游无响应)。请重试,或检查 FinMall 登录态 / 网络。`,
|
|
286
|
+
}
|
|
287
|
+
log.error('远端同步看门狗触发:任务超时,已代其收尾并释放同步锁', {
|
|
288
|
+
jobId: job.id,
|
|
289
|
+
timeoutMs: SYNC_JOB_TIMEOUT_MS,
|
|
290
|
+
done: job.progress.done,
|
|
291
|
+
currentId: job.progress.currentId,
|
|
292
|
+
})
|
|
293
|
+
}, SYNC_JOB_TIMEOUT_MS)
|
|
294
|
+
if (typeof watchdog.unref === 'function') watchdog.unref()
|
|
295
|
+
|
|
258
296
|
// 后台跑同步:请求立即返回,前端轮询 progress
|
|
259
297
|
;(async () => {
|
|
260
298
|
try {
|
|
@@ -281,12 +319,15 @@ export function createSyncMiddleware({ root }) {
|
|
|
281
319
|
},
|
|
282
320
|
shouldCancel: () => job.cancelRequested,
|
|
283
321
|
})
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
322
|
+
// 看门狗已代收尾时不回滚结论:用户当时已经收到「超时」结论,迟到的成功会造成错觉
|
|
323
|
+
if (!job.timedOut) {
|
|
324
|
+
job.result = {
|
|
325
|
+
success: !!summary.success,
|
|
326
|
+
cancelled: !!summary.cancelled,
|
|
327
|
+
synced: summary.synced || [],
|
|
328
|
+
failed: summary.failed || [],
|
|
329
|
+
duplicates: summary.duplicates || [],
|
|
330
|
+
}
|
|
290
331
|
}
|
|
291
332
|
logSyncSummary(summary, cardCode ? cardCode : cardCodes ? `勾选 ${cardCodes.length} 张` : '(全部)')
|
|
292
333
|
} catch (e) {
|
|
@@ -297,6 +338,7 @@ export function createSyncMiddleware({ root }) {
|
|
|
297
338
|
log.error('远端同步失败', e)
|
|
298
339
|
}
|
|
299
340
|
} finally {
|
|
341
|
+
clearTimeout(watchdog)
|
|
300
342
|
job.finished = true
|
|
301
343
|
}
|
|
302
344
|
})()
|
|
@@ -14,8 +14,10 @@ import { createSyncMiddleware, createPreviewMiddleware, createLoginCredentialsMi
|
|
|
14
14
|
import { createPublishMiddleware } from './cardPublish.js'
|
|
15
15
|
import { createCardVersionMiddleware } from './cardVersion.js'
|
|
16
16
|
import { createCardHistoryMiddleware } from './cardHistoryRemote.js'
|
|
17
|
+
import { createCardPublishVersionsMiddleware } from './cardPublishVersions.js'
|
|
17
18
|
import { createSliceUploadMiddleware } from './sliceUpload.js'
|
|
18
19
|
import { createCardFilesMiddleware, refreshCardList } from './cardFiles.js'
|
|
20
|
+
import { createExportMiddleware } from './cardExport.js'
|
|
19
21
|
import { createSaveMiddleware } from './cardSave.js'
|
|
20
22
|
import { createImportMetaMiddleware } from './cardImportMeta.js'
|
|
21
23
|
import { createHistoryMiddleware } from './cardHistory.js'
|
|
@@ -48,10 +50,12 @@ export {
|
|
|
48
50
|
createPublishMiddleware,
|
|
49
51
|
createCardVersionMiddleware,
|
|
50
52
|
createCardHistoryMiddleware,
|
|
53
|
+
createCardPublishVersionsMiddleware,
|
|
51
54
|
createSliceUploadMiddleware,
|
|
52
55
|
createCardFilesMiddleware,
|
|
53
56
|
createSaveMiddleware,
|
|
54
57
|
createImportMetaMiddleware,
|
|
58
|
+
createExportMiddleware,
|
|
55
59
|
createHistoryMiddleware,
|
|
56
60
|
createValidateMiddleware,
|
|
57
61
|
createRenderStatusMiddleware,
|
|
@@ -103,12 +107,19 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
|
|
|
103
107
|
// 「卡片历史记录」tab 的远端版本时间线(只读):GET /__card-history/:cardCode 取 IDC10013 的
|
|
104
108
|
// schemaValueList(新→旧,含预览图 / 变更标签),复用 finmall-card-sync 的 fetchCardHistory。
|
|
105
109
|
app.use(createCardHistoryMiddleware({ root }))
|
|
110
|
+
// 「导入信息」tab「发布版本记录」弹框的数据源(只读):GET /__card-publish-versions/:cardCode
|
|
111
|
+
// 取 IDC10004 的 versionList(新→旧,版本号 / 平台 / 发布状态 / 发布时间),滚动到底懒加载下一页。
|
|
112
|
+
app.use(createCardPublishVersionsMiddleware({ root }))
|
|
106
113
|
// 切图上传(顶栏「远端 → 切图上传」):把当前卡引用的 src/slices 切图传到 FinMall 图片库,
|
|
107
114
|
// 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
|
|
108
115
|
// 仅在用户点击时触发,没有任何自动上传路径。
|
|
109
116
|
app.use(createSliceUploadMiddleware({ root }))
|
|
110
117
|
app.use(createCardFilesMiddleware({ root, notify }))
|
|
111
118
|
app.use(createSaveMiddleware({ root }))
|
|
119
|
+
// 卡片导出(编辑器「网格预览 → 勾选 → 导出」):产物契约见 server/core/cardExport.js 头注释
|
|
120
|
+
// 与 docs/卡片导出产物逆向分析与导出功能.md;previewSchema 经 viteServer.ssrLoadModule 复用
|
|
121
|
+
// 编辑器同源 buildPreview,故本中间件需要 viteServer(无则退化为 schema 原样序列化)。
|
|
122
|
+
app.use(createExportMiddleware({ root, viteServer, upstream }))
|
|
112
123
|
app.use(createHistoryMiddleware({ root }))
|
|
113
124
|
app.use(createValidateMiddleware({ root }))
|
|
114
125
|
app.use(createRenderStatusMiddleware({ root, viteServer }))
|
|
@@ -212,6 +212,52 @@ headers: token / iCIFID / eCIFID = localStorage._sessionId(只在页面上下
|
|
|
212
212
|
| CLI 跑完进程不退出 | 已修:CLI 末尾显式 `process.exit`(后台 Chrome 的 CDP 连接会挂住事件循环);Chrome 进程本身按约定常驻不关 |
|
|
213
213
|
| 想撤销改写 | `git checkout -- src/cards/<id>/`;清单里的地址仍有效,重跑一次即可再改写回来 |
|
|
214
214
|
|
|
215
|
+
## 卡片预览图上传(推送成功后自动,`IDC10014.do`)
|
|
216
|
+
|
|
217
|
+
编辑器顶栏「远端 → 推送」走 `POST /__save`(`saveCardToRemote`)。**推送成功后**,服务端会用无头
|
|
218
|
+
Chrome 渲染当前卡片(`server/core/screenshot.js → generateCardCover`:透明底、与卡片实际渲染尺寸
|
|
219
|
+
1:1 的 PNG),再把这张图作为**远端列表预览图**上传(`uploadCardPreview` → `IDC10014.do`),
|
|
220
|
+
使 FinMall 卡片列表的缩略图与刚推上去的内容一致。
|
|
221
|
+
|
|
222
|
+
| 项 | 值 |
|
|
223
|
+
|---|---|
|
|
224
|
+
| 触发点 | 仅 `/__save` 成功后,一个请求内完成(前端 `saveToRemote` 已带 2.5s 自保存抑制) |
|
|
225
|
+
| 总预算 | 45s(`CARDBUDDY_PUSH_PREVIEW_TIMEOUT_MS`);到点放弃等待,响应带 `preview.error` |
|
|
226
|
+
| 开关 | `CARDBUDDY_PUSH_PREVIEW=0` 关闭(远端图片库维护时避免每条推送多等一次失败) |
|
|
227
|
+
| 失败语义 | **best-effort**:日志 + 响应 `preview:{ok:false,error,message}`,推送本身的成功结论不变 |
|
|
228
|
+
|
|
229
|
+
### 接口契约(`IDC10014.do`,取自线上编辑器抓包)
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
POST {baseUrl}/ubomp-web/IDC10014.do # 注意:sassAppId/Workspace 走表单,不是查询串
|
|
233
|
+
Content-Type: multipart/form-data
|
|
234
|
+
cardId = 10000363 # 数字型 cardId(= schema.pageId)
|
|
235
|
+
file = <二进制 PNG> # filename = <cardId>.png
|
|
236
|
+
sassAppId = <FINMALL_APP_ID>
|
|
237
|
+
sassWorkspaceId = <FINMALL_WORKSPACE_ID>
|
|
238
|
+
headers: token = localStorage._sessionId(只在页面上下文内读取,不出页面)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
与切图上传(`CC0000.do`)的四点差异,改动时别混用:①字段是 `cardId` + `file`(**没有** `fileName`
|
|
242
|
+
纯文本字段);②`sassAppId/Workspace` 在表单里;③filename 固定 `<cardId>.png`(抓包里该 part 的
|
|
243
|
+
Content-Type 是 `application/octet-stream`,照抄);④鉴权头只有 `token`。
|
|
244
|
+
|
|
245
|
+
响应判定按 FinMall 通用信封做**宽松**处理(`parsePreviewUploadResponse`):空响应体视为成功;
|
|
246
|
+
非 JSON 非空(网关 HTML 错误页)报错;`header.errorCode` 与 `body.errorCode` **逐个**检查
|
|
247
|
+
(不能用 `a || b` 短路——`header.errorCode='0'` 会把 body 的业务错误盖掉);成功时尽力取
|
|
248
|
+
`body.picUrl | previewUrl | imageUrl | filePath | url`,取不到不影响「上传成功」。命中
|
|
249
|
+
`BLEC0006` 等会话失效特征时自动清 session 重登并重试一次(与同步 / 切图同一套自愈)。
|
|
250
|
+
|
|
251
|
+
### 两条刻意的「不做」
|
|
252
|
+
|
|
253
|
+
- **不写本地预览图**:不覆盖 `src/cards/<id>/<id>.png`。截图+上传耗时可能超过前端 2.5s 自保存
|
|
254
|
+
抑制窗口,写盘会触发 watcher 的 `cards-changed`,误弹「卡片已在编辑器外被修改」;本地封面仍由
|
|
255
|
+
顶栏「预览图」按钮(`POST /__card-image/<id>`)显式生成——单一触发点。
|
|
256
|
+
- **不参与同步 / 发布**:拉取方向由远端 `picUrl` 覆盖本地封面;「卡片发布」只上线版本,不改预览图。
|
|
257
|
+
|
|
258
|
+
> 截图渲染的是**磁盘态** schema,故前端 `saveToRemote` 在有未保存编辑(`historyDirty`)时会先
|
|
259
|
+
> 落盘再推送(与「切图上传」同一处置口径),否则会截到上一版布局。
|
|
260
|
+
|
|
215
261
|
## 变量与数据规则
|
|
216
262
|
|
|
217
263
|
编辑器变量面板是 `schema.dictionary.inputParams[]` 的 UI 呈现:
|