Files
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- 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.
2026-09-13 22:36:19 +08:00

378 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()
}