cardbuddy 0.21.0 → 0.22.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/dist/cli.js +2 -1
- package/dist/preview-editor/assets/{index-CpDojF__.js → index-CPpLZZyi.js} +122 -100
- package/dist/preview-editor/assets/index-CU8zVA2P.css +1 -0
- package/dist/preview-editor/index.html +2 -2
- package/dist/vendored/server/core/cardExport.js +197 -39
- package/dist/vendored/server/core/cardGitCommit.js +190 -0
- package/dist/vendored/server/core/cardPublish.js +36 -2
- package/dist/vendored/server/core/cardPublishVersions.js +301 -4
- package/dist/vendored/server/core/index.js +6 -0
- package/dist/vendored/server/core/remoteCard.js +207 -0
- package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +424 -6
- package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +39 -0
- package/dist/vendored/skills/finmall-session-navigator/scripts/package-lock.json +7 -24
- package/package.json +6 -6
- package/dist/preview-editor/assets/index-C8VLzU18.css +0 -1
|
@@ -7,26 +7,51 @@ import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
|
7
7
|
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* 调试面板「导入信息」tab
|
|
11
|
-
*
|
|
10
|
+
* 调试面板「导入信息」tab「发布版本记录」弹框的远端数据源:
|
|
11
|
+
* GET /__card-publish-versions/:cardCode?pageNo=&pageSize= → 版本记录分页(IDC10004)
|
|
12
|
+
* POST /__card-publish-versions/:cardCode/stop → 停止发布(IDC20003)
|
|
13
|
+
* POST /__card-publish-versions/:cardCode/publish → 创建发布任务(IDC20004)
|
|
12
14
|
*
|
|
13
15
|
* 语义:经 .import-meta.json 侧车里的 cardId 调 IDC10004,取该卡发布版本记录分页(新→旧),
|
|
14
|
-
* 每条含版本号 / 平台(iOS·Android·HarmonyOS 子集)/ 发布状态 /
|
|
16
|
+
* 每条含版本号 / 平台(iOS·Android·HarmonyOS 子集)/ 发布状态 / 发布时间,
|
|
17
|
+
* 以及该版本仍在进行中的「发布任务」(task,含取消发布所需的 taskId)。
|
|
15
18
|
* 触发时机由前端控制(打开弹框 / 滚动到底懒加载下一页),不自动轮询——与「编辑记录」tab
|
|
16
19
|
* 的懒加载策略一致,避免每次加载卡片都拉起后台 Chrome。
|
|
17
20
|
*
|
|
18
21
|
* 安全闸(与 /__card-history / /__card-version 同源):仅「已同步导入卡」(有
|
|
19
22
|
* .import-meta.json 侧车且含 cardId)可查;本地自建卡 / 未同步卡返回 409 local_card_no_source。
|
|
20
23
|
*
|
|
24
|
+
* 停止发布(IDC20003)作用在「发布任务」而非版本记录上,taskId 与 cardId 之间没有直接约束,
|
|
25
|
+
* 因此这里做两道归属校验再下发:
|
|
26
|
+
* 1) versionId 必须出现在该卡的发布版本记录里(防止拿别人的 versionId 越权);
|
|
27
|
+
* 2) taskId 必须属于该 versionId 的任务列表(防止伪造 taskId 停掉无关任务)。
|
|
28
|
+
* 校验不过一律 404,不把远端错误直接抛给前端。
|
|
29
|
+
*
|
|
21
30
|
* @param {{ root: string }} opts
|
|
22
31
|
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
23
32
|
*/
|
|
24
33
|
export function createCardPublishVersionsMiddleware({ root }) {
|
|
25
34
|
return function cardPublishVersionsMiddleware(req, res, next) {
|
|
26
35
|
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
36
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
37
|
+
|
|
38
|
+
// ---- 停止发布:POST /__card-publish-versions/:cardCode/stop ----
|
|
39
|
+
const stopMatch = pathname.match(/^\/__card-publish-versions\/([^/]+)\/stop$/)
|
|
40
|
+
if (stopMatch) {
|
|
41
|
+
if (method !== 'POST') return next()
|
|
42
|
+
return handleStopPublish({ root, cardCode: decodeURIComponent(stopMatch[1]), req, res })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- 创建发布:POST /__card-publish-versions/:cardCode/publish ----
|
|
46
|
+
const publishMatch = pathname.match(/^\/__card-publish-versions\/([^/]+)\/publish$/)
|
|
47
|
+
if (publishMatch) {
|
|
48
|
+
if (method !== 'POST') return next()
|
|
49
|
+
return handlePublishVersion({ root, cardCode: decodeURIComponent(publishMatch[1]), req, res })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (method !== 'GET') return next()
|
|
27
53
|
const m = pathname.match(/^\/__card-publish-versions\/([^/]+)$/)
|
|
28
54
|
if (!m) return next()
|
|
29
|
-
if ((req.method || 'GET').toUpperCase() !== 'GET') return next()
|
|
30
55
|
|
|
31
56
|
const id = decodeURIComponent(m[1])
|
|
32
57
|
const dir = findCardDir(root, { code: id })
|
|
@@ -89,3 +114,275 @@ export function createCardPublishVersionsMiddleware({ root }) {
|
|
|
89
114
|
})()
|
|
90
115
|
}
|
|
91
116
|
}
|
|
117
|
+
|
|
118
|
+
/** 统一 JSON 响应(与 cardExport / remoteCard 同风格) */
|
|
119
|
+
function sendJson(res, status, obj) {
|
|
120
|
+
res.statusCode = status
|
|
121
|
+
res.setHeader('Content-Type', 'application/json')
|
|
122
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
123
|
+
res.end(JSON.stringify(obj))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 读 JSON 请求体;上限 64KB(停止发布只传三个 id 字段,无需大 body) */
|
|
127
|
+
function readJsonBody(req, res, cb) {
|
|
128
|
+
let body = ''
|
|
129
|
+
let done = false
|
|
130
|
+
req.setEncoding('utf8')
|
|
131
|
+
req.on('data', (c) => {
|
|
132
|
+
if (done) return
|
|
133
|
+
body += c
|
|
134
|
+
if (body.length > 64 * 1024) {
|
|
135
|
+
done = true
|
|
136
|
+
req.destroy()
|
|
137
|
+
sendJson(res, 413, { ok: false, error: 'body too large', message: '请求体超过 64KB。' })
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
req.on('end', () => {
|
|
141
|
+
if (done) return
|
|
142
|
+
done = true
|
|
143
|
+
let parsed
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(body || '{}')
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return sendJson(res, 400, { ok: false, error: 'invalid_json', message: String(e.message) })
|
|
148
|
+
}
|
|
149
|
+
cb(parsed && typeof parsed === 'object' ? parsed : {})
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 停止发布:POST /__card-publish-versions/:cardCode/stop,body { taskId, versionId, version? }
|
|
155
|
+
*
|
|
156
|
+
* 两道归属校验见上方中间件注释。校验通过后调 IDC20003(taskStatus:'0')。
|
|
157
|
+
* 已处于停止状态的任务返回 409(幂等语义上不算错,但对前端是一次无效点击,给明确反馈更好)。
|
|
158
|
+
*/
|
|
159
|
+
function handleStopPublish({ root, cardCode, req, res }) {
|
|
160
|
+
const dir = findCardDir(root, { code: cardCode })
|
|
161
|
+
const meta = dir ? readImportMeta(dir) : null
|
|
162
|
+
if (!meta || !meta.cardId) {
|
|
163
|
+
return sendJson(res, 409, {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: 'local_card_no_source',
|
|
166
|
+
message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法停止远端发布。',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
readJsonBody(req, res, (body) => {
|
|
171
|
+
const taskId = String(body.taskId == null ? '' : body.taskId).trim()
|
|
172
|
+
const versionId = String(body.versionId == null ? '' : body.versionId).trim()
|
|
173
|
+
const version = String(body.version == null ? '' : body.version).trim()
|
|
174
|
+
if (!/^\d+$/.test(taskId) || !/^\d+$/.test(versionId)) {
|
|
175
|
+
return sendJson(res, 400, {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: 'bad_request',
|
|
178
|
+
message: '停止发布需要数字型 taskId 与 versionId(均来自 IDC20002 的发布任务)。',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
;(async () => {
|
|
183
|
+
try {
|
|
184
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
185
|
+
const { fetchCardPublishVersions, fetchCardVersionTasks, updateCardTaskStatus } = await import(
|
|
186
|
+
pathToFileURL(skillPath).href
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
// 校验 1:versionId 必须是该卡发布记录中的一条。
|
|
190
|
+
// 带 version 时按版本号精确筛选(一条即可),否则取前 100 条兜底扫描。
|
|
191
|
+
const page = await fetchCardPublishVersions(meta.cardId, {
|
|
192
|
+
pageNo: 1,
|
|
193
|
+
pageSize: version ? 10 : 100,
|
|
194
|
+
version,
|
|
195
|
+
withTasks: false,
|
|
196
|
+
})
|
|
197
|
+
const row = (page.list || []).find((x) => x.versionId === versionId)
|
|
198
|
+
if (!row) {
|
|
199
|
+
return sendJson(res, 404, {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: 'version_not_found',
|
|
202
|
+
message: `版本记录 ${versionId} 不属于卡片 ${cardCode}(cardId=${meta.cardId}),已拒绝。`,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 校验 2:taskId 必须挂在该版本下。
|
|
207
|
+
const tasks = await fetchCardVersionTasks(versionId)
|
|
208
|
+
const target = tasks.find((t) => t.taskId === taskId)
|
|
209
|
+
if (!target) {
|
|
210
|
+
return sendJson(res, 404, {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: 'task_not_found',
|
|
213
|
+
message: `发布任务 ${taskId} 不属于版本 ${row.version},已拒绝。`,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
if (target.taskStatus === '0') {
|
|
217
|
+
return sendJson(res, 409, {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: 'task_already_stopped',
|
|
220
|
+
message: `版本 ${row.version} 的发布任务已处于停止状态,无需重复操作。`,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await updateCardTaskStatus({ taskId, taskStatus: '0' })
|
|
225
|
+
log.ok('停止远端发布任务', {
|
|
226
|
+
cardCode,
|
|
227
|
+
cardId: String(meta.cardId),
|
|
228
|
+
version: row.version,
|
|
229
|
+
versionId,
|
|
230
|
+
taskId,
|
|
231
|
+
})
|
|
232
|
+
sendJson(res, 200, {
|
|
233
|
+
ok: true,
|
|
234
|
+
cardCode,
|
|
235
|
+
cardId: String(meta.cardId),
|
|
236
|
+
version: row.version,
|
|
237
|
+
versionId,
|
|
238
|
+
taskId,
|
|
239
|
+
})
|
|
240
|
+
} catch (e) {
|
|
241
|
+
const raw = String((e && e.message) || '停止发布失败')
|
|
242
|
+
const isNoCred = /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(raw)
|
|
243
|
+
if (isNoCred) {
|
|
244
|
+
return sendJson(res, 200, {
|
|
245
|
+
ok: false,
|
|
246
|
+
error: 'need_credentials',
|
|
247
|
+
needCredentials: true,
|
|
248
|
+
message: raw,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
// finmallPost 抛出的是「FinMall 交易失败 [errorCode=IMVM0005] 状态修改失败 | {整包 JSON}」,
|
|
252
|
+
// 整包塞进 toast 读不过来:这里只取 errorMsg + code,其余留日志。
|
|
253
|
+
log.error('停止远端发布失败', { cardCode, versionId, taskId, error: raw.slice(0, 300) })
|
|
254
|
+
sendJson(res, 500, {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: 'stop_publish_failed',
|
|
257
|
+
message: finmallErrorMessage(raw, '远端拒绝了本次停止操作'),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
})()
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 创建发布任务:POST /__card-publish-versions/:cardCode/publish,body { versionId, version?, memo? }
|
|
266
|
+
*
|
|
267
|
+
* 对应线上详情页版本行的「创建发布」(tooltip:创建发布)→ 发布任务抽屉 → IDC20004.do。
|
|
268
|
+
* 线上抽屉有「发布类型(正式 / 灰度)」等表单,这里只走正式发布(publishType='2'),
|
|
269
|
+
* 灰度白名单 / 时间窗 / 高级规则等有真实需求再补。
|
|
270
|
+
*
|
|
271
|
+
* 归属校验与停止发布同源:versionId 必须出现在该卡的发布版本记录里(不接受外来 versionId)。
|
|
272
|
+
* 这道闸是必需的——实测 IDC20004 对不存在的 versionId 也返回「交易成功」并真的建出任务,
|
|
273
|
+
* 远端本身不校验归属。
|
|
274
|
+
*
|
|
275
|
+
* 唯一的拒绝条件是「该版本已有进行中的发布任务」(409):同一版本重复下发会生成两条并行的
|
|
276
|
+
* 发布任务,线上页面虽然不拦,但对我们这个批量工具属于误操作,409 比默默任务重复更好排查。
|
|
277
|
+
* 特意**不**拿 publishStatus 当拒绝理由:publishStatus='1' 只表示该版本已上线,与「有没有
|
|
278
|
+
* 任务在跑」无因果关系(实测存在发布中但 versionTaskList 为空的行),拿它拒绝会把
|
|
279
|
+
* 「已上线、无任务」这种正常行堵成死路——那种行既停不了也发不了。线上页面同样只看任务:
|
|
280
|
+
* 版本行的「创建发布」按钮没有任何 disabled 条件。
|
|
281
|
+
*/
|
|
282
|
+
function handlePublishVersion({ root, cardCode, req, res }) {
|
|
283
|
+
const dir = findCardDir(root, { code: cardCode })
|
|
284
|
+
const meta = dir ? readImportMeta(dir) : null
|
|
285
|
+
if (!meta || !meta.cardId) {
|
|
286
|
+
return sendJson(res, 409, {
|
|
287
|
+
ok: false,
|
|
288
|
+
error: 'local_card_no_source',
|
|
289
|
+
message: '该卡片为本地自建、无远端同步来源(缺少 .import-meta.json 侧车),无法创建远端发布。',
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
readJsonBody(req, res, (body) => {
|
|
294
|
+
const versionId = String(body.versionId == null ? '' : body.versionId).trim()
|
|
295
|
+
const version = String(body.version == null ? '' : body.version).trim()
|
|
296
|
+
const memo = String(body.memo == null ? '' : body.memo).trim().slice(0, 200)
|
|
297
|
+
if (!/^\d+$/.test(versionId)) {
|
|
298
|
+
return sendJson(res, 400, {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: 'bad_request',
|
|
301
|
+
message: '创建发布需要数字型 versionId(来自 IDC10004 的版本记录)。',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
;(async () => {
|
|
306
|
+
try {
|
|
307
|
+
const skillPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
308
|
+
const { fetchCardPublishVersions, createCardPublishTask } = await import(
|
|
309
|
+
pathToFileURL(skillPath).href
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// 归属校验 + 顺便拿到该行是否已有进行中的任务(带 version 时只查这一条,代价很小)。
|
|
313
|
+
// withTasks 恒开:409 判定依赖真实任务列表,不能因调用方没传 version 就跳过查询。
|
|
314
|
+
const page = await fetchCardPublishVersions(meta.cardId, {
|
|
315
|
+
pageNo: 1,
|
|
316
|
+
pageSize: version ? 10 : 100,
|
|
317
|
+
version,
|
|
318
|
+
withTasks: true,
|
|
319
|
+
})
|
|
320
|
+
const row = (page.list || []).find((x) => x.versionId === versionId)
|
|
321
|
+
if (!row) {
|
|
322
|
+
return sendJson(res, 404, {
|
|
323
|
+
ok: false,
|
|
324
|
+
error: 'version_not_found',
|
|
325
|
+
message: `版本记录 ${versionId} 不属于卡片 ${cardCode}(cardId=${meta.cardId}),已拒绝。`,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
if (row.task && row.task.taskStatus !== '0') {
|
|
329
|
+
return sendJson(res, 409, {
|
|
330
|
+
ok: false,
|
|
331
|
+
error: 'task_already_running',
|
|
332
|
+
message: `版本 ${row.version} 已有进行中的发布任务,如需重新发布请先停止。`,
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await createCardPublishTask({
|
|
337
|
+
cardId: meta.cardId,
|
|
338
|
+
versionId,
|
|
339
|
+
version: row.version,
|
|
340
|
+
memo,
|
|
341
|
+
})
|
|
342
|
+
log.ok('创建远端发布任务', {
|
|
343
|
+
cardCode,
|
|
344
|
+
cardId: String(meta.cardId),
|
|
345
|
+
version: row.version,
|
|
346
|
+
versionId,
|
|
347
|
+
})
|
|
348
|
+
sendJson(res, 200, {
|
|
349
|
+
ok: true,
|
|
350
|
+
cardCode,
|
|
351
|
+
cardId: String(meta.cardId),
|
|
352
|
+
version: row.version,
|
|
353
|
+
versionId,
|
|
354
|
+
publishType: '2',
|
|
355
|
+
})
|
|
356
|
+
} catch (e) {
|
|
357
|
+
const raw = String((e && e.message) || '创建发布失败')
|
|
358
|
+
const isNoCred = /credentials\.json|credentials 不存在|NO_CREDENTIALS/.test(raw)
|
|
359
|
+
if (isNoCred) {
|
|
360
|
+
return sendJson(res, 200, {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: 'need_credentials',
|
|
363
|
+
needCredentials: true,
|
|
364
|
+
message: raw,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
log.error('创建远端发布失败', { cardCode, versionId, error: raw.slice(0, 300) })
|
|
368
|
+
sendJson(res, 500, {
|
|
369
|
+
ok: false,
|
|
370
|
+
error: 'publish_failed',
|
|
371
|
+
message: finmallErrorMessage(raw, '远端拒绝了本次发布'),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
})()
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 把 finmallPost 抛出的原始错误压成一句可读文案。
|
|
380
|
+
* 原始形态:「FinMall 交易失败 [errorCode=IMVM0005] 状态修改失败 | {整包 JSON}」——
|
|
381
|
+
* 整包 JSON 塞进 toast 读不过来,只取 errorMsg + code,其余由调用方写日志。
|
|
382
|
+
*/
|
|
383
|
+
function finmallErrorMessage(raw, fallback) {
|
|
384
|
+
const m = String(raw || '').match(/FinMall 交易失败 \[errorCode=([^\]]+)\]\s*([^|]*)/)
|
|
385
|
+
if (!m) return raw
|
|
386
|
+
return `${(m[2] || '').trim() || fallback}(${m[1]})`
|
|
387
|
+
}
|
|
388
|
+
|
|
@@ -16,6 +16,7 @@ import { createCardVersionMiddleware } from './cardVersion.js'
|
|
|
16
16
|
import { createCardHistoryMiddleware } from './cardHistoryRemote.js'
|
|
17
17
|
import { createCardPublishVersionsMiddleware } from './cardPublishVersions.js'
|
|
18
18
|
import { createSliceUploadMiddleware } from './sliceUpload.js'
|
|
19
|
+
import { createRemoteCardMiddleware } from './remoteCard.js'
|
|
19
20
|
import { createCardFilesMiddleware, refreshCardList } from './cardFiles.js'
|
|
20
21
|
import { createExportMiddleware } from './cardExport.js'
|
|
21
22
|
import { createSaveMiddleware } from './cardSave.js'
|
|
@@ -52,6 +53,7 @@ export {
|
|
|
52
53
|
createCardHistoryMiddleware,
|
|
53
54
|
createCardPublishVersionsMiddleware,
|
|
54
55
|
createSliceUploadMiddleware,
|
|
56
|
+
createRemoteCardMiddleware,
|
|
55
57
|
createCardFilesMiddleware,
|
|
56
58
|
createSaveMiddleware,
|
|
57
59
|
createImportMetaMiddleware,
|
|
@@ -114,6 +116,10 @@ export function mountCoreMiddlewares(app, { root, viteServer = null, upstream =
|
|
|
114
116
|
// 写 src/slices/manifest.json 映射并把卡片里的 /slices/ 引用改写为 @filestore/ 线上地址。
|
|
115
117
|
// 仅在用户点击时触发,没有任何自动上传路径。
|
|
116
118
|
app.use(createSliceUploadMiddleware({ root }))
|
|
119
|
+
// 远端卡片基本信息维护(「远端全部卡片」弹窗的「添加卡片 / 编辑」抽屉):
|
|
120
|
+
// 类型字典 IDC10009、新建 IDC10002、编辑 IDC10008、封面上传 CC0000,
|
|
121
|
+
// 复用 finmall-card-sync 的后台会话;只改 FinMall 侧元数据,不写本地 src/cards。
|
|
122
|
+
app.use(createRemoteCardMiddleware({ root }))
|
|
117
123
|
app.use(createCardFilesMiddleware({ root, notify }))
|
|
118
124
|
app.use(createSaveMiddleware({ root }))
|
|
119
125
|
// 卡片导出(编辑器「网格预览 → 勾选 → 导出」):产物契约见 server/core/cardExport.js 头注释
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
import { log } from './logger.js'
|
|
3
|
+
import { resolveSkillPathOrProject } from './skillPaths.js'
|
|
4
|
+
|
|
5
|
+
/** 卡片元数据(列表字典 / 新建 / 编辑)所在脚本,相对 skills/ */
|
|
6
|
+
const SYNC_SCRIPT_REL = 'finmall-card-sync/scripts/api_batch_sync.mjs'
|
|
7
|
+
/** 图片上传(CC0000.do)所在脚本,与切图上传同一条链路 */
|
|
8
|
+
const SLICE_SCRIPT_REL = 'finmall-card-sync/scripts/slice_upload.mjs'
|
|
9
|
+
|
|
10
|
+
/** 封面 base64 请求体上限:一张 2x 卡片封面通常 < 1MB,留足余量又不至于吃满内存 */
|
|
11
|
+
const MAX_BODY_BYTES = 12 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 请求体读取(JSON)。上限按封面上传放宽到 12MB —— 封面是 base64 内联传输,
|
|
15
|
+
* 与其它端点(64KB~4MB)不是同一量级,用统一小上限会把正常封面直接掐断。
|
|
16
|
+
*/
|
|
17
|
+
function readJsonBody(req, res, cb) {
|
|
18
|
+
let body = ''
|
|
19
|
+
req.setEncoding('utf8')
|
|
20
|
+
req.on('data', (c) => {
|
|
21
|
+
body += c
|
|
22
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
23
|
+
res.statusCode = 413
|
|
24
|
+
res.setHeader('Content-Type', 'application/json')
|
|
25
|
+
res.end(JSON.stringify({ ok: false, error: 'body_too_large', message: '封面体积过大(上限 12MB)' }))
|
|
26
|
+
req.destroy()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
req.on('end', () => {
|
|
30
|
+
if (res.writableEnded) return
|
|
31
|
+
let payload
|
|
32
|
+
try {
|
|
33
|
+
payload = JSON.parse(body || '{}')
|
|
34
|
+
} catch (e) {
|
|
35
|
+
res.statusCode = 400
|
|
36
|
+
res.setHeader('Content-Type', 'application/json')
|
|
37
|
+
return res.end(JSON.stringify({ ok: false, error: 'invalid json', message: String(e.message) }))
|
|
38
|
+
}
|
|
39
|
+
cb(payload)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 统一响应:成功 200 + {ok:true,...};凭据缺失 200 + needCredentials(前端弹登录表单) */
|
|
44
|
+
function sendOk(res, payload) {
|
|
45
|
+
res.statusCode = 200
|
|
46
|
+
res.setHeader('Content-Type', 'application/json')
|
|
47
|
+
res.end(JSON.stringify({ ok: true, ...payload }))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sendError(res, err, label) {
|
|
51
|
+
const message = String((err && err.message) || err || '未知错误')
|
|
52
|
+
const needCred = /credentials|NO_CREDENTIALS|会话超时|请先.*登录|未登录/i.test(message)
|
|
53
|
+
if (needCred) {
|
|
54
|
+
// 凭据/会话类失败不是 500:前端据此打开登录表单,而不是弹一条红色错误码
|
|
55
|
+
res.statusCode = 200
|
|
56
|
+
res.setHeader('Content-Type', 'application/json')
|
|
57
|
+
return res.end(JSON.stringify({ ok: false, error: 'need_credentials', needCredentials: true, message }))
|
|
58
|
+
}
|
|
59
|
+
log.error(label, err)
|
|
60
|
+
res.statusCode = 500
|
|
61
|
+
res.setHeader('Content-Type', 'application/json')
|
|
62
|
+
res.end(JSON.stringify({ ok: false, error: label, message }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 远端卡片「基本信息」维护的服务端处理(编辑器「远端全部卡片」弹窗用):
|
|
67
|
+
*
|
|
68
|
+
* - GET /__remote-card-types → 卡片类型字典(IDC10009.do),新建/编辑抽屉的类型下拉
|
|
69
|
+
* - POST /__remote-card/create → 新建卡片(IDC10002.do),body { code, cardName, cardClassId, picUrl? }
|
|
70
|
+
* - POST /__remote-card/update → 编辑基本信息(IDC10008.do),body { cardId, code, cardName, cardClassId, picUrl? }
|
|
71
|
+
* - POST /__remote-card/delete → 删除卡片(IDC10003.do),body { cardId }(不可逆,前端须二次确认)
|
|
72
|
+
* - POST /__remote-card/cover → 上传封面图(CC0000.do),body { fileName, base64, mime? } → { url }
|
|
73
|
+
*
|
|
74
|
+
* 为什么必须经 Node 中转:FinMall 接口需要 `token` 头(值只在页面上下文内可读)、且跨域,
|
|
75
|
+
* 纯前端发不出去。这里复用 finmall-card-sync 的后台会话(与同步/切图上传同一个 Chrome),
|
|
76
|
+
* 不额外引入登录态。
|
|
77
|
+
*
|
|
78
|
+
* 注意:这些接口只改 FinMall 侧的卡片元数据,不写本地 src/cards ——
|
|
79
|
+
* 本地目录由「下载选中 / 全部下载」按需拉取,避免远端建卡即污染工作区。
|
|
80
|
+
*
|
|
81
|
+
* @param {{ root: string }} opts
|
|
82
|
+
* @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => void}
|
|
83
|
+
*/
|
|
84
|
+
export function createRemoteCardMiddleware({ root }) {
|
|
85
|
+
return function remoteCardMiddleware(req, res, next) {
|
|
86
|
+
const pathname = decodeURIComponent((req.url || '').split('?')[0])
|
|
87
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
88
|
+
|
|
89
|
+
// ---- 卡片类型字典 ----
|
|
90
|
+
if (pathname === '/__remote-card-types') {
|
|
91
|
+
if (method !== 'GET') return next()
|
|
92
|
+
;(async () => {
|
|
93
|
+
try {
|
|
94
|
+
const scriptPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
95
|
+
const { listRemoteCardTypes } = await import(pathToFileURL(scriptPath).href)
|
|
96
|
+
const types = await listRemoteCardTypes({})
|
|
97
|
+
sendOk(res, { types })
|
|
98
|
+
} catch (e) {
|
|
99
|
+
sendError(res, e, 'remote card types failed')
|
|
100
|
+
}
|
|
101
|
+
})()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- 新建卡片 ----
|
|
106
|
+
if (pathname === '/__remote-card/create') {
|
|
107
|
+
if (method !== 'POST') return next()
|
|
108
|
+
return readJsonBody(req, res, async (payload) => {
|
|
109
|
+
try {
|
|
110
|
+
if (!payload.code) {
|
|
111
|
+
res.statusCode = 400
|
|
112
|
+
res.setHeader('Content-Type', 'application/json')
|
|
113
|
+
return res.end(JSON.stringify({ ok: false, error: 'missing code', message: '需填写卡片编码' }))
|
|
114
|
+
}
|
|
115
|
+
const scriptPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
116
|
+
const { createRemoteCard } = await import(pathToFileURL(scriptPath).href)
|
|
117
|
+
const created = await createRemoteCard({
|
|
118
|
+
code: payload.code,
|
|
119
|
+
cardName: payload.cardName,
|
|
120
|
+
cardClassId: payload.cardClassId,
|
|
121
|
+
picUrl: payload.picUrl,
|
|
122
|
+
})
|
|
123
|
+
log.info('已在远端新建卡片', { code: created.code, cardId: created.cardId })
|
|
124
|
+
sendOk(res, { cardId: created.cardId, code: created.code })
|
|
125
|
+
} catch (e) {
|
|
126
|
+
sendError(res, e, 'remote card create failed')
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- 编辑卡片基本信息 ----
|
|
132
|
+
if (pathname === '/__remote-card/update') {
|
|
133
|
+
if (method !== 'POST') return next()
|
|
134
|
+
return readJsonBody(req, res, async (payload) => {
|
|
135
|
+
try {
|
|
136
|
+
if (!payload.cardId) {
|
|
137
|
+
res.statusCode = 400
|
|
138
|
+
res.setHeader('Content-Type', 'application/json')
|
|
139
|
+
return res.end(JSON.stringify({ ok: false, error: 'missing cardId', message: '需传入远端卡片 cardId' }))
|
|
140
|
+
}
|
|
141
|
+
const scriptPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
142
|
+
const { updateRemoteCard } = await import(pathToFileURL(scriptPath).href)
|
|
143
|
+
const updated = await updateRemoteCard({
|
|
144
|
+
cardId: payload.cardId,
|
|
145
|
+
code: payload.code,
|
|
146
|
+
cardName: payload.cardName,
|
|
147
|
+
cardClassId: payload.cardClassId,
|
|
148
|
+
picUrl: payload.picUrl,
|
|
149
|
+
})
|
|
150
|
+
log.info('已更新远端卡片基本信息', { code: updated.code, cardId: updated.cardId })
|
|
151
|
+
sendOk(res, { cardId: updated.cardId, code: updated.code })
|
|
152
|
+
} catch (e) {
|
|
153
|
+
sendError(res, e, 'remote card update failed')
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- 删除卡片(不可逆)----
|
|
159
|
+
if (pathname === '/__remote-card/delete') {
|
|
160
|
+
if (method !== 'POST') return next()
|
|
161
|
+
return readJsonBody(req, res, async (payload) => {
|
|
162
|
+
try {
|
|
163
|
+
if (!payload.cardId) {
|
|
164
|
+
res.statusCode = 400
|
|
165
|
+
res.setHeader('Content-Type', 'application/json')
|
|
166
|
+
return res.end(JSON.stringify({ ok: false, error: 'missing cardId', message: '需传入远端卡片 cardId' }))
|
|
167
|
+
}
|
|
168
|
+
const scriptPath = resolveSkillPathOrProject(root, SYNC_SCRIPT_REL)
|
|
169
|
+
const { deleteRemoteCard } = await import(pathToFileURL(scriptPath).href)
|
|
170
|
+
const deleted = await deleteRemoteCard({ cardId: payload.cardId, channel: payload.channel })
|
|
171
|
+
log.info('已删除远端卡片', { cardId: deleted.cardId })
|
|
172
|
+
sendOk(res, { cardId: deleted.cardId })
|
|
173
|
+
} catch (e) {
|
|
174
|
+
sendError(res, e, 'remote card delete failed')
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- 封面上传 ----
|
|
180
|
+
if (pathname === '/__remote-card/cover') {
|
|
181
|
+
if (method !== 'POST') return next()
|
|
182
|
+
return readJsonBody(req, res, async (payload) => {
|
|
183
|
+
try {
|
|
184
|
+
if (!payload.fileName || !payload.base64) {
|
|
185
|
+
res.statusCode = 400
|
|
186
|
+
res.setHeader('Content-Type', 'application/json')
|
|
187
|
+
return res.end(
|
|
188
|
+
JSON.stringify({ ok: false, error: 'missing file', message: '需传入 fileName 与 base64 图片内容' }),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
const scriptPath = resolveSkillPathOrProject(root, SLICE_SCRIPT_REL)
|
|
192
|
+
const { uploadCardImage } = await import(pathToFileURL(scriptPath).href)
|
|
193
|
+
const result = await uploadCardImage({
|
|
194
|
+
fileName: payload.fileName,
|
|
195
|
+
base64: payload.base64,
|
|
196
|
+
mime: payload.mime,
|
|
197
|
+
})
|
|
198
|
+
sendOk(res, { url: result.url, dimensions: result.dimensions })
|
|
199
|
+
} catch (e) {
|
|
200
|
+
sendError(res, e, 'remote card cover upload failed')
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return next()
|
|
206
|
+
}
|
|
207
|
+
}
|