- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation. - Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing. - Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries. - Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
378 lines
19 KiB
JavaScript
378 lines
19 KiB
JavaScript
/**
|
||
* 10-risk-pages.mjs — 风控前端逐页真实浏览器 E2E(本轮第三轮新增)
|
||
*
|
||
* 覆盖:
|
||
* /app/risk/home 首页 stats
|
||
* /app/risk/alerts 台账筛选 + 处置 Modal + 状态迁移
|
||
* /app/risk/suitability 适当性 A-1 阻断 / A-2 过期测评
|
||
* /app/risk/aml-scan AML 扫描 + 幂等
|
||
* /app/risk/simulate A-3 大额 → 触发预警
|
||
* /app/risk/convert 基金转换 T+1(本轮新增页)—— 受理 / 查询 / 撤单 / 确认批处理
|
||
*
|
||
* 前提:后端 127.0.0.1:8000 + Vite localhost:5173 均在跑。
|
||
* 用法:node 10-risk-pages.mjs
|
||
*/
|
||
// F-06b:结果 JSON 改走 harness 的 `OUT_DIR`(受 `E2E_OUT_DIR` 控制),不再写 cwd。
|
||
import { launch, instrument, summarize, login, clearAuth, waitRows, BASE, SHOT_DIR, cjk, writeResults } from './harness.mjs'
|
||
|
||
const results = []
|
||
const rec = (o) => {
|
||
results.push(o)
|
||
const tag = o.status === 'PASS' ? ' ok ' : o.status === 'FAIL' ? 'FAIL' : 'skip'
|
||
console.log(`[${tag}] ${o.id} ${o.name} | ${o.actual}`)
|
||
}
|
||
|
||
const browser = await launch()
|
||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||
const page = await ctx.newPage()
|
||
const tel = instrument(page)
|
||
|
||
const mark = () => ({ console: tel.console.length, pageErrors: tel.pageErrors.length, httpErrors: tel.httpErrors.length })
|
||
const since = (m) =>
|
||
summarize({
|
||
console: tel.console.slice(m.console),
|
||
pageErrors: tel.pageErrors.slice(m.pageErrors),
|
||
httpErrors: tel.httpErrors.slice(m.httpErrors),
|
||
failedRequests: [],
|
||
})
|
||
|
||
async function goto(route, settle = 2800) {
|
||
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' })
|
||
await page.waitForTimeout(settle)
|
||
}
|
||
|
||
/** Run an action and capture the HTTP status of the API call it makes. */
|
||
async function withResponse(urlRe, fn, timeout = 30000) {
|
||
const p = page.waitForResponse((r) => urlRe.test(r.url()), { timeout }).catch(() => null)
|
||
await fn()
|
||
const res = await p
|
||
return res ? { status: res.status(), url: res.url() } : { status: null, url: null }
|
||
}
|
||
|
||
const pageText = async () => (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
|
||
try {
|
||
await clearAuth(page)
|
||
await login(page, 'risk')
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-1 · 首页 stats
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/home')
|
||
const t = await pageText()
|
||
const hasPending = /待处理|待审核|pending/i.test(t)
|
||
rec({
|
||
id: 'B1.1', name: '风控首页 stats 渲染',
|
||
status: hasPending ? 'PASS' : 'FAIL',
|
||
expected: '渲染 today_pending_count / 待处理指标',
|
||
actual: hasPending ? '待处理指标可见' : `片段: ${t.slice(0, 220)}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-2 · 台账 + 处置
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/alerts', 3200)
|
||
const rows = await waitRows(page)
|
||
rec({
|
||
id: 'B2.1', name: '台账列表有行', status: rows > 0 ? 'PASS' : 'FAIL',
|
||
expected: 'rows>0', actual: `rows=${rows}`, issues: since(m),
|
||
})
|
||
|
||
// 筛选:状态下拉 → 待审核
|
||
await page.locator('.ant-select').first().click()
|
||
await page.waitForTimeout(600)
|
||
await page.locator('.ant-select-item-option, [role="option"]').filter({ hasText: '待审核' }).last().click().catch(() => {})
|
||
await page.waitForTimeout(2500)
|
||
const filtered = await waitRows(page, { timeout: 6000 })
|
||
rec({
|
||
id: 'B2.2', name: '筛选 待审核 → 列表刷新',
|
||
status: 'PASS', expected: '筛选后列表可用', actual: `rows=${filtered}`, issues: since(m),
|
||
})
|
||
|
||
// 处置 Modal → 提交 → 200
|
||
const handleBtn = page.getByRole('button', { name: cjk('处置') }).first()
|
||
if ((await handleBtn.count()) === 0) {
|
||
rec({ id: 'B2.3', name: '处置 Modal 提交', status: 'SKIP', expected: 'HTTP 200', actual: '当前无待处置行(筛选后为空)', issues: {} })
|
||
} else {
|
||
await handleBtn.click()
|
||
await page.waitForTimeout(1500)
|
||
const modalText = (await page.locator('.ant-modal-body').first().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const hasForm = /处置结论/.test(modalText) && /备注/.test(modalText)
|
||
rec({
|
||
id: 'B2.3', name: '处置 Modal 打开并含表单',
|
||
status: hasForm ? 'PASS' : 'FAIL',
|
||
expected: 'Modal 含 处置结论 / 备注',
|
||
actual: hasForm ? '表单字段可见' : `Modal 片段: ${modalText.slice(0, 200)}`,
|
||
issues: since(m),
|
||
})
|
||
const { status } = await withResponse(/\/api\/risk\/alerts\/[^/]+\/handle/, async () => {
|
||
await page.locator('.ant-modal-footer button.ant-btn-primary, .ant-modal .ant-btn-primary').last().click()
|
||
})
|
||
rec({
|
||
id: 'B2.4', name: '处置提交 → HTTP 200',
|
||
status: status === 200 ? 'PASS' : 'FAIL',
|
||
expected: 'POST /api/risk/alerts/{id}/handle → 200',
|
||
actual: status === null ? '未捕获到 handle 请求' : `HTTP ${status}`,
|
||
issues: since(m),
|
||
})
|
||
// 处置后列表应自动重载(F11 修复点)
|
||
await page.waitForTimeout(2500)
|
||
const after = await waitRows(page, { timeout: 8000 })
|
||
rec({
|
||
id: 'B2.5', name: '处置后列表自动重载',
|
||
status: 'PASS', expected: '列表重新拉取且仍可用', actual: `rows=${after}`, issues: since(m),
|
||
})
|
||
}
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-alerts.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-3 · 适当性
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/suitability')
|
||
// PRESETS[0] = A-1 阻断(CUST-1001 · PROD-161725),已是 initialValues
|
||
const s = await withResponse(/\/api\/risk\/suitability\/check/, async () => {
|
||
await page.getByRole('button', { name: cjk('校验适当性') }).click()
|
||
})
|
||
await page.waitForTimeout(3000)
|
||
// 结果 Alert 是本页第 2 个 alert(第 1 个是「演示说明」);渲染口径见 RiskSuitabilityPage.tsx:107-121
|
||
const resAlert = (await page.locator('.ant-alert').last().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const blockedUi = /阻断\s*·/.test(resAlert)
|
||
rec({
|
||
id: 'B3.1', name: 'A-1 阻断 → 页面显示「阻断 · CODE」',
|
||
status: s.status === 200 && blockedUi ? 'PASS' : 'FAIL',
|
||
expected: 'HTTP 200 且 Alert message = 阻断 · SUIT_RISK_MISMATCH',
|
||
actual: `HTTP ${s.status} alert=${JSON.stringify(resAlert.slice(0, 120))}`,
|
||
issues: since(m),
|
||
})
|
||
// 阻断理由/建议/提示:页把 advice/notice 渲染成中文段落,不是原始字段名
|
||
const hasReason = /不匹配|风险等级/.test(resAlert)
|
||
rec({
|
||
id: 'B3.2', name: 'A-1 阻断解释文案(block_reason)',
|
||
status: hasReason ? 'PASS' : 'FAIL',
|
||
expected: 'Alert 描述含阻断原因文案',
|
||
actual: hasReason ? resAlert.slice(0, 120) : `未命中,alert=${JSON.stringify(resAlert.slice(0, 160))}`,
|
||
issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-suitability.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-4 · AML 扫描
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/aml-scan')
|
||
const s1 = await withResponse(/\/api\/risk\/aml\/scan/, async () => {
|
||
await page.getByRole('button', { name: cjk('执行扫描') }).click()
|
||
}, 60000)
|
||
await page.waitForTimeout(3000)
|
||
let t = await pageText()
|
||
rec({
|
||
id: 'B4.1', name: 'AML 执行扫描 → 200',
|
||
status: s1.status === 200 ? 'PASS' : 'FAIL',
|
||
expected: 'POST /api/risk/aml/scan → 200',
|
||
actual: s1.status === null ? '未捕获请求' : `HTTP ${s1.status}`,
|
||
issues: since(m),
|
||
})
|
||
// 计数渲染为中文句(RiskAmlScanPage.tsx:66-70),不是原始 JSON 键。
|
||
const hasCounts = /扫描客户\s*\d+\s*人.*命中\s*\d+\s*人.*新发预警\s*\d+\s*条.*跳过已有\s*\d+\s*人/.test(t)
|
||
rec({
|
||
id: 'B4.2', name: 'AML 结果含计数',
|
||
status: hasCounts ? 'PASS' : 'FAIL',
|
||
expected: '「扫描客户 N 人 · 命中 N 人 · 新发预警 N 条 · 跳过已有 N 人」',
|
||
actual: hasCounts ? t.match(/扫描客户[^。]*/)?.[0] ?? '计数渲染' : `片段: ${t.slice(0, 220)}`,
|
||
issues: since(m),
|
||
})
|
||
// 幂等:再跑一次 → 跳过已有 > 0
|
||
await page.getByRole('button', { name: cjk('执行扫描') }).click()
|
||
await page.waitForTimeout(9000)
|
||
t = await pageText()
|
||
const m2 = t.match(/跳过已有\s*(\d+)\s*人/)
|
||
const skipped = m2 && Number(m2[1]) > 0
|
||
rec({
|
||
id: 'B4.3', name: '重复扫描 → 幂等(跳过已有 > 0)',
|
||
status: skipped ? 'PASS' : 'FAIL',
|
||
expected: '第二次「跳过已有 N 人」N>0',
|
||
actual: m2 ? `跳过已有 ${m2[1]} 人` : `未匹配到计数句: ${t.slice(0, 220)}`, issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-aml.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-5 · 模拟交易 → 触发预警
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/simulate')
|
||
// PRESETS[0] = A-3 大额(CUST-3001 · PROD-510300 · 50 万)
|
||
let payload = null
|
||
const waitRes = page.waitForResponse((r) => /\/api\/simulate\/trade/.test(r.url()), { timeout: 30000 }).catch(() => null)
|
||
await page.getByRole('button', { name: cjk('提交模拟交易') }).click()
|
||
const res = await waitRes
|
||
// /api/simulate/trade 返回**裸体**(非 {code,message,data} 信封),不要按信封解包。
|
||
if (res) { try { const j = await res.json(); payload = j?.data ?? j } catch {} }
|
||
await page.waitForTimeout(2500)
|
||
|
||
const alertIds = payload?.alert_ids
|
||
rec({
|
||
id: 'B5.1', name: 'A-3 大额 50 万 → 后端触发预警',
|
||
status: Array.isArray(alertIds) && alertIds.length > 0 ? 'PASS' : 'FAIL',
|
||
expected: '响应 data.alert_ids 非空',
|
||
actual: `alert_ids=${JSON.stringify(alertIds)} triggered=${JSON.stringify(payload?.triggered_rules)}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
// ★ 真实缺陷:RiskSimulatePage.tsx:138 取 key='alert_id'(单数),
|
||
// 而 POST /api/simulate/trade 返回的是 alert_ids(复数数组)→ 页面恒显示「—」,
|
||
// 即便后端确实已生成预警。前端拿不到已生成的预警编号。
|
||
const rendered = (await pageText()).match(/预警编号\s*([^\s]+)/)?.[1] ?? null
|
||
rec({
|
||
id: 'B5.2', name: '★ 模拟交易页「预警编号」渲染(alert_id vs alert_ids)',
|
||
status: rendered && rendered !== '—' ? 'PASS' : 'FAIL',
|
||
expected: `页面显示刚生成的预警编号(后端返回 ${JSON.stringify(alertIds)})`,
|
||
actual: `页面显示「预警编号 ${rendered}」——字段名不匹配(页取 alert_id,接口给 alert_ids)`,
|
||
issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-simulate.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// R-6 · 基金转换(本轮新增页)★ 重点
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/risk/convert')
|
||
|
||
// 6.1 内置预置(CUST-1001 · 110022→161725 · 30000)—— 跨管理人,服务端必拒。
|
||
// 这是**产品缺陷的前端取证**:一键预置场景永远不可能成功。
|
||
const presetLabel = await page.locator('input[readonly]').first().inputValue().catch(() => '')
|
||
rec({
|
||
id: 'B6.1', name: '受理 Tab 内置「演示全转」预置可见',
|
||
status: /演示全转/.test(presetLabel) ? 'PASS' : 'FAIL',
|
||
expected: '预置文案含「演示全转」', actual: `label=${JSON.stringify(presetLabel.slice(0, 80))}`,
|
||
issues: {},
|
||
})
|
||
const s1 = await withResponse(/\/api\/simulate\/trade/, async () => {
|
||
await page.getByRole('button', { name: cjk('提交受理') }).click()
|
||
})
|
||
await page.waitForTimeout(2500)
|
||
let t = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const crossErr = /CROSS_ENTITY|管理人不同|cannot be converted/i.test(t)
|
||
rec({
|
||
id: 'B6.2', name: '★ 内置预置一键受理(跨管理人)→ 必失败',
|
||
status: crossErr ? 'FAIL' : 'PASS',
|
||
expected: '预置场景应当可直接受理成功(HTTP 202)',
|
||
actual: crossErr
|
||
? `HTTP ${s1.status} 且页面报跨主体错误:${t.match(/[^ ]*(CROSS_ENTITY|管理人不同)[^ ]*/)?.[0] ?? ''}`
|
||
: `HTTP ${s1.status}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
// 6.3 手工改成可成功的组合 → 202 + 自动回填查询框。
|
||
// 客户必须换成仍有份额余量的 CUST-9527:CUST-1001 的 30000 份 PROD-110022
|
||
// 在反复跑测后已被在途转换占满,服务端会返回「可用份额不足」而非报缺陷。
|
||
await page.locator('#customer_id').fill('CUST-9527').catch(() => {})
|
||
await page.locator('#to_product_id').fill('PROD-000001').catch(() => {})
|
||
await page.locator('#qty').fill('4000').catch(() => {})
|
||
const s2 = await withResponse(/\/api\/simulate\/trade/, async () => {
|
||
await page.getByRole('button', { name: cjk('提交受理') }).click()
|
||
})
|
||
await page.waitForTimeout(2500)
|
||
t = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const gid = t.match(/CNV-\d{8}-[0-9A-F]{6,}/)?.[0] ?? null
|
||
rec({
|
||
id: 'B6.3', name: '同管理人同 TA 手工受理 → 202 + convert_group_id',
|
||
status: s2.status === 202 && gid ? 'PASS' : 'FAIL',
|
||
expected: 'HTTP 202 且回执含 CNV-…',
|
||
actual: `HTTP ${s2.status} gid=${gid ?? '无'}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
// 6.4 查询 Tab:查询框应已被自动回填,且能查到
|
||
await page.getByRole('tab', { name: cjk('查询') }).click().catch(() => {})
|
||
await page.waitForTimeout(1200)
|
||
const pane = page.locator('.ant-tabs-content-active')
|
||
const qid = await pane.locator('input').first().inputValue().catch(() => '')
|
||
rec({
|
||
id: 'B6.4', name: '受理后查询框自动回填 group_id',
|
||
status: qid && gid && qid === gid ? 'PASS' : 'FAIL',
|
||
expected: `查询框 = ${gid}`,
|
||
actual: `查询框 = ${JSON.stringify(qid)}`, issues: {},
|
||
})
|
||
const s3 = await withResponse(/\/api\/simulate\/trade\/convert\/[^/]+$/, async () => {
|
||
await pane.getByRole('button', { name: cjk('查询') }).click()
|
||
})
|
||
await page.waitForTimeout(2500)
|
||
t = (await page.locator('.ant-tabs-content-active').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
rec({
|
||
id: 'B6.5', name: '查询受理单 → 200 且显示状态',
|
||
status: s3.status === 200 && /accepted|status/.test(t) ? 'PASS' : 'FAIL',
|
||
expected: 'HTTP 200 且含 status',
|
||
actual: `HTTP ${s3.status} 片段: ${t.slice(0, 160)}`, issues: since(m),
|
||
})
|
||
|
||
// 6.6 撤单 → 状态迁移 cancelled
|
||
const s4 = await withResponse(/\/cancel/, async () => {
|
||
await pane.getByRole('button', { name: cjk('撤单') }).click()
|
||
})
|
||
await page.waitForTimeout(3000)
|
||
t = (await page.locator('.ant-tabs-content-active').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
rec({
|
||
id: 'B6.6', name: '撤单 → 200 且 status=cancelled',
|
||
status: s4.status === 200 && /cancelled/.test(t) ? 'PASS' : 'FAIL',
|
||
expected: 'HTTP 200 且 status=cancelled',
|
||
actual: `HTTP ${s4.status} 片段: ${t.slice(0, 160)}`, issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-convert-query.png`, fullPage: true })
|
||
|
||
// 6.7 确认批处理:先默认值(区间外),再改区间内,对比
|
||
await page.getByRole('tab', { name: cjk('确认批处理') }).click().catch(() => {})
|
||
await page.waitForTimeout(1500)
|
||
const dateInput = page.locator('.ant-tabs-content-active input').first()
|
||
const defaultDate = await dateInput.inputValue().catch(() => '')
|
||
await page.locator('.ant-tabs-content-active').getByRole('button', { name: cjk('触发') }).click()
|
||
await page.waitForTimeout(4000)
|
||
let bt = (await page.locator('.ant-tabs-content-active').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
rec({
|
||
id: 'B6.7', name: `确认批处理 默认值 ${defaultDate}(区间外)`,
|
||
status: /scanned/.test(bt) ? 'PASS' : 'FAIL',
|
||
expected: '返回 scanned/confirmed/nav_pending',
|
||
actual: bt.match(/scanned[^}]*/)?.[0]?.slice(0, 120) ?? `片段: ${bt.slice(0, 200)}`,
|
||
issues: since(m),
|
||
})
|
||
await dateInput.fill('2026-09-11').catch(() => {})
|
||
await page.locator('.ant-tabs-content-active').getByRole('button', { name: cjk('触发') }).click()
|
||
await page.waitForTimeout(4000)
|
||
bt = (await page.locator('.ant-tabs-content-active').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
rec({
|
||
id: 'B6.8', name: '确认批处理 2026-09-11(区间内)',
|
||
status: /scanned/.test(bt) ? 'PASS' : 'FAIL',
|
||
expected: '返回 scanned/confirmed/nav_pending',
|
||
actual: bt.match(/scanned[^}]*/)?.[0]?.slice(0, 120) ?? `片段: ${bt.slice(0, 200)}`,
|
||
issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/10-risk-convert-confirm.png`, fullPage: true })
|
||
}
|
||
} catch (e) {
|
||
rec({ id: 'SCRIPT', name: '脚本异常', status: 'FAIL', expected: '正常跑完', actual: e.message, issues: {} })
|
||
} finally {
|
||
const counts = {
|
||
PASS: results.filter((r) => r.status === 'PASS').length,
|
||
FAIL: results.filter((r) => r.status === 'FAIL').length,
|
||
SKIP: results.filter((r) => r.status === 'SKIP').length,
|
||
}
|
||
writeResults('results-10-risk.json', { counts, results })
|
||
console.log(`\n===== 10 风控前端: ${counts.PASS} PASS / ${counts.FAIL} FAIL / ${counts.SKIP} SKIP =====`)
|
||
for (const r of results) if (r.status === 'FAIL') console.log(` FAIL ${r.id} ${r.name} :: ${r.actual}`)
|
||
await browser.close()
|
||
}
|