/** * 前端(Node 侧)契约冒烟:用与浏览器**同一条数据通道**(网关同源 + 相对路径)跑一遍冻契约端点。 * * 为什么要它:Vue 页面在浏览器里执行,CI/命令行里没有浏览器;本脚本用 Node 24 自带的 fetch * 走同一网关地址与同一批路径,把"前端能不能按契约取到后端与算法服务的数据"变成可命令行复核的事实。 * * 用法: * node app_frontEnd/web/tools/contract_smoke.mjs # 默认 http://127.0.0.1:28084 * node app_frontEnd/web/tools/contract_smoke.mjs http://127.0.0.1:28084 /web * 退出码:0 全部与契约一致 · 9 有不一致 · 2 契约文件读不到 */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' const HERE = dirname(fileURLToPath(import.meta.url)) const CONTRACT = resolve(HERE, '../../../app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json') const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084' const WEB_PREFIX = process.argv[3] || '/web' let contract try { contract = JSON.parse(readFileSync(CONTRACT, 'utf8')) } catch (e) { console.error('[X] 读不到契约正本', CONTRACT, e.message) process.exit(2) } const KIND_BASE = { gateway: GATEWAY, frontend: GATEWAY, cms: GATEWAY, detail_api: GATEWAY + '/detail', detail_page: GATEWAY + '/detail', algorithm: GATEWAY + '/algorithm', } let bad = 0 let n = 0 console.log(`== 前端契约冒烟(Node fetch · 网关 ${GATEWAY})==`) for (const e of contract.endpoints) { const url = (KIND_BASE[e.kind] || GATEWAY) + e.path n += 1 let status = 0 let body = '' const t0 = Date.now() try { const r = await fetch(url, { method: e.method, headers: { Accept: '*/*' } }) status = r.status body = await r.text() } catch (err) { status = 0 body = String(err && err.message) } const ms = Date.now() - t0 let why = '' if (status !== e.status) why = `状态 ${status} ≠ ${e.status}` else if (e.json_keys_any) { try { const o = JSON.parse(body) const okSet = e.json_keys_any.some((set) => set.every((k) => o && typeof o === 'object' && k in o)) if (!okSet) why = '响应键集不在契约允许的任一种里' } catch { why = '响应不是 JSON' } } else if (e.json_keys) { try { const o = JSON.parse(body) const miss = e.json_keys.filter((k) => !(o && typeof o === 'object' && k in o)) if (miss.length) why = '缺顶层键 ' + miss.slice(0, 5).join(',') } catch { why = '响应不是 JSON' } } else if (e.html_title) { const m = body.match(/(.*?)<\/title>/s) const title = m ? m[1].trim() : '' if (title !== e.html_title) why = `标题 ${title.slice(0, 24)} ≠ ${e.html_title.slice(0, 24)}` } if (why) { bad += 1 console.log(` [X] ${e.method} ${e.path} → ${why} (${ms} ms)`) } } // Vue 前端自身的可访问性(经网关 /web 前缀) try { const r = await fetch(GATEWAY + WEB_PREFIX + '/index.html') const text = await r.text() const ok = r.status === 200 && text.includes('id="app"') console.log(` [${ok ? 'OK' : 'X '}] Vue 前端 ${WEB_PREFIX}/index.html → ${r.status} · 挂载点 ${text.includes('id="app"')}`) if (!ok) bad += 1 } catch (e) { console.log(' [X] Vue 前端不可达:', e.message) bad += 1 } console.log(`\n契约端点 ${n} 条 · 不一致 ${bad} 条`) console.log('结论:', bad === 0 ? '前端数据通道与契约一致' : '存在不一致(见上)') process.exit(bad === 0 ? 0 : 9)