feat(web): 基金转换演示页与 agent 镜像表迁移; Core seed 修复

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 17:59:10 +08:00
co-authored by Cursor
parent 03fce883d4
commit c09b987ffe
7 changed files with 341 additions and 15 deletions
+4 -1
View File
@@ -8,7 +8,10 @@
**2026-09-12 · 基金转换 T+1**(分支 **`integrate/risk-convert`** · `risk-control-agent` → `merger` 方案 A · 待合入 `merger`)
- [x] merge 冲突解完(15 文件)· `main.py` 保留平台/问数/顾问 + convert 路由 · `core_ro` 并集 + 顾问行情别名
- [ ] 本机 `reset.ps1` 灌 Core convert 种子 · 全量 pytest 回归 · fast-forward **`merger`**
- [x] fast-forward **`merger`** → `03fce88`(`integrate/risk-convert`)
- [x] 本机 Core convert 种子(`verify_convert_seed.py` + nav-history 可选)· `verify_convert_api.py` 9/9
- [x] 前端:`/app/risk/convert` + `web/src/api/convert.ts`
- [ ] 全量 pytest 回归
**2026-09-12 · 投资顾问 Agent 合并**(分支 **`integrate/advisor-agent`** · 目标合入 **`merger`**)
@@ -0,0 +1,24 @@
USE jinrong_agent;
CREATE TABLE IF NOT EXISTS risk_convert_detail (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
convert_group_id VARCHAR(64) NOT NULL,
status ENUM('pending','completed','failed','cancelled','expired') NOT NULL DEFAULT 'pending',
out_trade_id VARCHAR(64) NULL,
in_trade_id VARCHAR(64) NULL,
related_trade_id VARCHAR(64) NULL,
nav DECIMAL(10,4) NULL,
nav_date DATE NULL,
fee_amount DECIMAL(18,2) NULL,
hold_days_min INT NULL,
hold_days_max INT NULL,
estimated TINYINT(1) NOT NULL DEFAULT 1,
nav_stale TINYINT(1) NOT NULL DEFAULT 0,
client_request_id VARCHAR(64) NULL,
cancelled_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_group (convert_group_id),
UNIQUE KEY uk_idem (client_request_id),
KEY idx_out (out_trade_id),
KEY idx_in (in_trade_id)
) ENGINE=InnoDB COMMENT='基金转换详情(Agent 侧)';
+26 -14
View File
@@ -1,18 +1,30 @@
USE jinrong_core;
USE jinrong_core;
-- 基金净值种子:**真实历史净值,非人造数值**
-- 生成方式:python scripts/core/fetch_nav.py --days 60
-- 数据来源:天天基金(东方财富)公开接口 https://fund.eastmoney.com/pingzhongdata/{代码}.js
-- 抓取时间:2026-09-12 14:56:55
-- 区间:2026-06-22 ~ 2026-09-11(共 60 个净值发布日)
--
-- 产品 ↔ 净值来源映射(产品 ID 为虚构,数值借用下列真实基金,按类型/风险等级配对):
-- PROD-000002 <- 014437 鹏华中证同业存单AAA指数7天持有(bond/R1:同业存单,日波动 0.01%)
-- PROD-003095 <- 003095 中欧医疗健康混合A(stock/R4:医药主题,代码即真身)
-- PROD-005827 <- 005827 易方达蓝筹精选混合(mixed/R3:代码即真身,股债平衡)
-- PROD-005828 <- 001182 易方达安心回馈混合A(mixed/R2:低波混合,日波动 0.73%)
-- PROD-110022 <- 000191 富国信用债债券A/B(bond/R1:纯债,日波动 0.02%)
-- PROD-110023 <- 000032 易方达信用债债券A(bond/R2:信用债,日波动 0.09%)
-- PROD-161725 <- 161631 融通人工智能指数(LOF)A(stock/R4:科技主题,日波动 2.57%)
-- PROD-161726 <- 110022 易方达消费行业股票(stock/R4:消费主题,代码即真身)
-- PROD-510300 <- 510300 沪深300ETF华泰柏瑞(index/R3:宽基指数,代码即真身)
-- PROD-510500 <- 510500 中证500ETF南方(index/R4:宽基指数,代码即真身)
-- PROD-PRIV01 <- 519062 海富通阿尔法对冲混合A(private_fund/R4:量化对冲,日波动 0.33%)
-- PROD-WMG001 <- 004155 中信保诚至泰中短债A(wealth_mgmt/R2:中短债,日波动 0.02%)
-- PROD-XYZ999 <- 320007 诺安成长混合A(stock/R5:高波动成长,日波动 1.76%)
-- PROD-000001 <- 恒定 1.0000(现金宝货币(money/R1:货基单位净值恒为 1 元,收益以份额结转))
--
-- ⚠️ 真实净值只到抓取当日。之后运行需重跑 fetch_nav.py 增量补拉,
-- 否则 T+1 确认会取不到 T 日净值(真实业务同样是「净值 T+1 才公告」)。
-- 鍩洪噾鍑€鍊肩瀛愶細**鐪熷疄鍘嗗彶鍑€鍊硷紝闈炰汉閫犳暟鍊?*
-- 鐢熸垚鏂瑰紡锛歱ython scripts/core/fetch_nav.py --days 60
-- 鏁版嵁鏉ユ簮锛氬ぉ澶╁熀閲戯紙涓滄柟璐㈠瘜锛夊叕寮€鎺ュ彛 https://fund.eastmoney.com/pingzhongdata/{浠g爜}.js
-- 鎶撳彇鏃堕棿锛?026-09-12 14:56:55
-- 鍖洪棿锛?026-06-22 ~ 2026-09-11锛堝叡 60 涓噣鍊煎彂甯冩棩锛?--
-- 浜у搧 鈫?鍑€鍊兼潵婧愭槧灏勶紙浜у搧 ID 涓鸿櫄鏋勶紝鏁板€煎€熺敤涓嬪垪鐪熷疄鍩洪噾锛屾寜绫诲瀷/椋庨櫓绛夌骇閰嶅锛夛細
-- PROD-000002 <- 014437 楣忓崕涓瘉鍚屼笟瀛樺崟AAA鎸囨暟7澶╂寔鏈夛紙bond/R1锛氬悓涓氬瓨鍗曪紝鏃ユ尝鍔?0.01%锛?-- PROD-003095 <- 003095 涓鍖荤枟鍋ュ悍娣峰悎A锛坰tock/R4锛氬尰鑽富棰橈紝浠g爜鍗崇湡韬級
-- PROD-005827 <- 005827 鏄撴柟杈捐摑绛圭簿閫夋贩鍚堬紙mixed/R3锛氫唬鐮佸嵆鐪熻韩锛岃偂鍊哄钩琛★級
-- PROD-005828 <- 001182 鏄撴柟杈惧畨蹇冨洖棣堟贩鍚圓锛坢ixed/R2锛氫綆娉㈡贩鍚堬紝鏃ユ尝鍔?0.73%锛?-- PROD-110022 <- 000191 瀵屽浗淇$敤鍊哄€哄埜A/B锛坆ond/R1锛氱函鍊猴紝鏃ユ尝鍔?0.02%锛?-- PROD-110023 <- 000032 鏄撴柟杈句俊鐢ㄥ€哄€哄埜A锛坆ond/R2锛氫俊鐢ㄥ€猴紝鏃ユ尝鍔?0.09%锛?-- PROD-161725 <- 161631 铻嶉€氫汉宸ユ櫤鑳芥寚鏁?LOF)A锛坰tock/R4锛氱鎶€涓婚锛屾棩娉㈠姩 2.57%锛?-- PROD-161726 <- 110022 鏄撴柟杈炬秷璐硅涓氳偂绁紙stock/R4锛氭秷璐逛富棰橈紝浠g爜鍗崇湡韬級
-- PROD-510300 <- 510300 娌繁300ETF鍗庢嘲鏌忕憺锛坕ndex/R3锛氬鍩烘寚鏁帮紝浠g爜鍗崇湡韬級
-- PROD-510500 <- 510500 涓瘉500ETF鍗楁柟锛坕ndex/R4锛氬鍩烘寚鏁帮紝浠g爜鍗崇湡韬級
-- PROD-PRIV01 <- 519062 娴峰瘜閫氶樋灏旀硶瀵瑰啿娣峰悎A锛坧rivate_fund/R4锛氶噺鍖栧鍐诧紝鏃ユ尝鍔?0.33%锛?-- PROD-WMG001 <- 004155 涓俊淇濊瘹鑷虫嘲涓煭鍊篈锛坵ealth_mgmt/R2锛氫腑鐭€猴紝鏃ユ尝鍔?0.02%锛?-- PROD-XYZ999 <- 320007 璇哄畨鎴愰暱娣峰悎A锛坰tock/R5锛氶珮娉㈠姩鎴愰暱锛屾棩娉㈠姩 1.76%锛?-- PROD-000001 <- 鎭掑畾 1.0000锛堢幇閲戝疂璐у竵锛坢oney/R1锛氳揣鍩哄崟浣嶅噣鍊兼亽涓?1 鍏冿紝鏀剁泭浠ヤ唤棰濈粨杞級锛?--
-- 鈿狅笍 鐪熷疄鍑€鍊煎彧鍒版姄鍙栧綋鏃ャ€備箣鍚庤繍琛岄渶閲嶈窇 fetch_nav.py 澧為噺琛ユ媺锛?-- 鍚﹀垯 T+1 纭浼氬彇涓嶅埌 T 鏃ュ噣鍊硷紙鐪熷疄涓氬姟鍚屾牱鏄€屽噣鍊?T+1 鎵嶅叕鍛娿€嶏級銆?
INSERT INTO core_product_nav (product_id, nav, daily_chg_pct, nav_date) VALUES
('PROD-000001', 1.0000, 0.0000, '2026-06-22'),
('PROD-000001', 1.0000, 0.0000, '2026-06-23'),
+2
View File
@@ -23,6 +23,7 @@ import { ProductNavDetailPage } from './pages/market/ProductNavDetailPage'
import { RiskAlertsPage } from './pages/risk/RiskAlertsPage'
import { RiskChatPage } from './pages/risk/RiskChatPage'
import { RiskSimulatePage } from './pages/risk/RiskSimulatePage'
import { RiskConvertPage } from './pages/risk/RiskConvertPage'
import { RiskSuitabilityPage } from './pages/risk/RiskSuitabilityPage'
import { RiskAmlScanPage } from './pages/risk/RiskAmlScanPage'
import { ChatPanel } from './components/chat'
@@ -109,6 +110,7 @@ export default function AppRoutes() {
<Route path="risk/suitability" element={<RiskSuitabilityPage />} />
<Route path="risk/aml-scan" element={<RiskAmlScanPage />} />
<Route path="risk/simulate" element={<RiskSimulatePage />} />
<Route path="risk/convert" element={<RiskConvertPage />} />
<Route path="risk/chat" element={<RiskChatPage />} />
<Route path="market" element={<MarketQuotesPage />} />
+72
View File
@@ -0,0 +1,72 @@
import { apiFetch } from './client'
import type { AgentType } from './chat'
function platformHeaders(token: string, agentType: AgentType = 'risk'): HeadersInit {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Agent-Type': agentType,
}
}
export type ConvertAcceptPayload = {
customer_id: string
trade_type: 'convert'
from_product_id: string
to_product_id: string
qty: number
client_request_id?: string
}
export type ConvertAcceptResponse = {
accepted?: boolean
convert_group_id?: string
status?: string
blocked?: boolean
message?: string
[key: string]: unknown
}
/** POST /api/simulate/trade — trade_type=convert,成功受理为 HTTP 202。 */
export async function submitConvertAccept(token: string, payload: ConvertAcceptPayload) {
const { data } = await apiFetch<ConvertAcceptResponse>('/api/simulate/trade', {
method: 'POST',
token,
headers: platformHeaders(token, 'risk'),
body: JSON.stringify(payload),
})
return data
}
export type ConvertQueryResponse = Record<string, unknown>
/** GET /api/simulate/trade/convert/{convert_group_id} */
export async function getConvertRequest(token: string, convertGroupId: string) {
const { data } = await apiFetch<ConvertQueryResponse>(
`/api/simulate/trade/convert/${encodeURIComponent(convertGroupId)}`,
{ token, headers: platformHeaders(token, 'risk') },
)
return data
}
/** POST /api/simulate/trade/convert/{convert_group_id}/cancel */
export async function cancelConvertRequest(token: string, convertGroupId: string) {
const { data } = await apiFetch<Record<string, unknown>>(
`/api/simulate/trade/convert/${encodeURIComponent(convertGroupId)}/cancel`,
{ method: 'POST', token, headers: platformHeaders(token, 'risk'), body: JSON.stringify({}) },
)
return data
}
/** POST /api/admin/convert/confirm?accept_date=YYYY-MM-DD — 需 risk_officer。 */
export async function confirmConvertBatch(token: string, acceptDate: string) {
const q = new URLSearchParams({ accept_date: acceptDate })
const { data } = await apiFetch<Record<string, unknown>>(`/api/admin/convert/confirm?${q}`, {
method: 'POST',
token,
headers: platformHeaders(token, 'risk'),
body: JSON.stringify({}),
})
return data
}
+212
View File
@@ -0,0 +1,212 @@
import { Alert, Form, Input, InputNumber, Tabs, Typography } from 'antd'
import { useState } from 'react'
import { ApiErrorResult } from '../../components/ApiErrorResult'
import { PageShell } from '../../components/PageShell'
import { Button } from '../../components/ui'
import {
cancelConvertRequest,
confirmConvertBatch,
getConvertRequest,
submitConvertAccept,
} from '../../api/convert'
import { ApiError } from '../../api/client'
import { RiskJsonResult } from '../../components/risk/RiskJsonResult'
import { useAppAuth } from '../../layouts/AppLayout'
const ACCEPT_PRESETS = [
{
key: 'demo',
label: '演示全转(CUST-1001 · 110022→161725 · 30000 份)',
values: {
customer_id: 'CUST-1001',
from_product_id: 'PROD-110022',
to_product_id: 'PROD-161725',
qty: 30000,
},
},
]
export function RiskConvertPage() {
const auth = useAppAuth()
const [acceptForm] = Form.useForm()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [acceptResult, setAcceptResult] = useState<Record<string, unknown> | null>(null)
const [queryId, setQueryId] = useState('')
const [queryResult, setQueryResult] = useState<Record<string, unknown> | null>(null)
const [confirmDate, setConfirmDate] = useState('2026-09-15')
const [confirmResult, setConfirmResult] = useState<Record<string, unknown> | null>(null)
const onAccept = async () => {
const values = await acceptForm.validateFields()
setLoading(true)
setError(null)
setAcceptResult(null)
try {
const data = await submitConvertAccept(auth.accessToken, {
customer_id: values.customer_id,
trade_type: 'convert',
from_product_id: values.from_product_id,
to_product_id: values.to_product_id,
qty: values.qty,
client_request_id: values.client_request_id || undefined,
})
setAcceptResult(data as Record<string, unknown>)
if (data.convert_group_id) setQueryId(String(data.convert_group_id))
} catch (e) {
setError(e instanceof Error ? e : new Error('accept failed'))
} finally {
setLoading(false)
}
}
const onQuery = async () => {
if (!queryId.trim()) return
setLoading(true)
setError(null)
try {
const data = await getConvertRequest(auth.accessToken, queryId.trim())
setQueryResult(data)
} catch (e) {
setError(e instanceof Error ? e : new Error('query failed'))
} finally {
setLoading(false)
}
}
const onCancel = async () => {
if (!queryId.trim()) return
setLoading(true)
setError(null)
try {
const data = await cancelConvertRequest(auth.accessToken, queryId.trim())
setQueryResult(data)
await onQuery()
} catch (e) {
setError(e instanceof Error ? e : new Error('cancel failed'))
} finally {
setLoading(false)
}
}
const onConfirmBatch = async () => {
setLoading(true)
setError(null)
setConfirmResult(null)
try {
const data = await confirmConvertBatch(auth.accessToken, confirmDate)
setConfirmResult(data)
} catch (e) {
setError(e instanceof Error ? e : new Error('confirm failed'))
} finally {
setLoading(false)
}
}
return (
<PageShell
title="基金转换(T+1)"
crumbs={[
{ title: '风控工作台', path: '/app/risk/home' },
{ title: '基金转换' },
]}
>
<Alert
type="info"
showIcon
className="mb-6"
message="演示说明"
description={
<>
受理 <Typography.Text code>POST /api/simulate/trade</Typography.Text>(convert)· 查询/撤单{' '}
<Typography.Text code>/api/simulate/trade/convert/…</Typography.Text> · 确认批处理{' '}
<Typography.Text code>POST /api/admin/convert/confirm</Typography.Text>(需 risk_officer,如 STAFF-30001)。
Core 需已灌 convert 种子(<Typography.Text code>verify_convert_seed.py</Typography.Text>)。
</>
}
/>
{error && <ApiErrorResult error={error as ApiError} />}
<Tabs
items={[
{
key: 'accept',
label: '受理',
children: (
<div className="max-w-xl space-y-4">
<Form form={acceptForm} layout="vertical" initialValues={ACCEPT_PRESETS[0].values}>
<Form.Item label="快捷场景">
<Input
readOnly
value={ACCEPT_PRESETS[0].label}
onClick={() => acceptForm.setFieldsValue(ACCEPT_PRESETS[0].values)}
/>
</Form.Item>
<Form.Item name="customer_id" label="客户 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="from_product_id" label="转出产品" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="to_product_id" label="转入产品" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="qty" label="申请份额" rules={[{ required: true }]}>
<InputNumber min={0.01} className="w-full" />
</Form.Item>
<Form.Item name="client_request_id" label="幂等键(可选)">
<Input placeholder="client_request_id" />
</Form.Item>
</Form>
<Button variant="primary" loading={loading} onClick={onAccept}>
提交受理
</Button>
<RiskJsonResult title="受理回执" data={acceptResult} />
</div>
),
},
{
key: 'query',
label: '查询 / 撤单',
children: (
<div className="max-w-xl space-y-4">
<Input
value={queryId}
onChange={(e) => setQueryId(e.target.value)}
placeholder="convert_group_id"
/>
<div className="flex gap-2">
<Button variant="primary" loading={loading} onClick={onQuery}>
查询
</Button>
<Button loading={loading} onClick={onCancel}>
撤单
</Button>
</div>
<RiskJsonResult title="查询结果" data={queryResult} />
</div>
),
},
{
key: 'confirm',
label: '确认批处理',
children: (
<div className="max-w-md space-y-4">
<Input
value={confirmDate}
onChange={(e) => setConfirmDate(e.target.value)}
placeholder="业务确认日 YYYY-MM-DD"
/>
<Button variant="primary" loading={loading} onClick={onConfirmBatch}>
触发 confirm_batch
</Button>
<RiskJsonResult title="批处理结果" data={confirmResult} />
</div>
),
},
]}
/>
</PageShell>
)
}
+1
View File
@@ -129,6 +129,7 @@ export function buildMenuGroups(roleLabel: string): RouteMenuGroup[] {
{ key: '/app/risk/suitability', icon: <CheckCircleOutlined />, label: '适当性校验', path: '/app/risk/suitability' },
{ key: '/app/risk/aml-scan', icon: <SafetyCertificateOutlined />, label: 'AML 扫描', path: '/app/risk/aml-scan' },
{ key: '/app/risk/simulate', icon: <SwapOutlined />, label: '模拟交易', path: '/app/risk/simulate' },
{ key: '/app/risk/convert', icon: <SwapOutlined />, label: '基金转换', path: '/app/risk/convert' },
],
},
{