Commit 5b9d1940 authored by drigle's avatar drigle

feat: 接入模型监控数据源并加强审批校验

parent 41e4f24d
......@@ -133,5 +133,6 @@ export default defineConfig({
define: {
STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser',
MODEL_MONITOR_CONNECTOR_NAME: process.env.MODEL_MONITOR_CONNECTOR_NAME || 'MODEL_MONITOR_ORACLE',
},
});
-- MySQL 5.7+ fixture data for the model-monitor source connector.
-- This script is repeatable: existing fixture rows are updated by their stable keys.
CREATE TABLE IF NOT EXISTS ANALYZE_MXVIEW_INFO (
ID VARCHAR(64) NOT NULL COMMENT '来源模型 ID',
MODEL_NAME VARCHAR(128) NOT NULL COMMENT '模型技术名称',
MODEL_ZH_NAME VARCHAR(128) NULL COMMENT '模型中文名称',
MODEL_DATA_UPDATE_TIME DATETIME(3) NULL COMMENT '模型数据更新时间',
MODEL_STATE VARCHAR(30) NULL COMMENT '模型状态',
MODEL_APPLY_STATE VARCHAR(30) NULL COMMENT '模型应用状态',
MODEL_RISK_LEVEL VARCHAR(30) NULL COMMENT '模型风险等级',
MODEL_BUSINESS_TYPE VARCHAR(30) NULL COMMENT '模型业务类型',
MODEL_BUSINESS_TYPE_NAME VARCHAR(128) NULL COMMENT '模型业务类型名称',
PRIMARY KEY (ID),
UNIQUE KEY UK_ANALYZE_MXVIEW_INFO_MODEL (MODEL_NAME, ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模型主档来源表';
CREATE TABLE IF NOT EXISTS ANALYZE_MODEL_RUN_BATCH_INFO (
MODEL_RUN_BATCH_ID VARCHAR(64) NOT NULL COMMENT '来源跑批 ID',
MODEL_ID VARCHAR(64) NOT NULL COMMENT '来源模型 ID',
MODEL_NAME VARCHAR(128) NOT NULL COMMENT '模型技术名称',
MODEL_RUN_BATCH_DATE DATETIME(3) NOT NULL COMMENT '来源跑批时间',
MODEL_DATA_TABLE_NAME VARCHAR(128) NOT NULL COMMENT '模型结果表名',
REMARK VARCHAR(1000) NULL COMMENT '来源备注',
MODEL_DATA_CNT INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '模型结果行数',
UNCHECKED_CNT INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '未检查数量',
UNSUBMITTED_CNT INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '未提交数量',
REVIEWED_CNT INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已复核数量',
RUN_BATCH_STATE VARCHAR(30) NOT NULL DEFAULT 'success' COMMENT '跑批状态',
ERROR_MESSAGE VARCHAR(4000) NULL COMMENT '失败原因',
PRIMARY KEY (MODEL_RUN_BATCH_ID),
KEY IDX_ANALYZE_BATCH_MODEL_DATE (MODEL_ID, MODEL_RUN_BATCH_DATE),
CONSTRAINT FK_ANALYZE_BATCH_MODEL
FOREIGN KEY (MODEL_ID) REFERENCES ANALYZE_MXVIEW_INFO (ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模型运行批次来源表';
CREATE TABLE IF NOT EXISTS ANALYZE_MODEL_DATA_MX_430 (
MODEL_ID VARCHAR(64) NOT NULL COMMENT '来源模型 ID',
MODEL_NAME VARCHAR(128) NOT NULL COMMENT '模型技术名称',
MODEL_RUN_BATCH_ID VARCHAR(64) NOT NULL COMMENT '来源跑批 ID',
MODEL_RUN_BATCH_DATE DATETIME(3) NOT NULL COMMENT '来源跑批时间',
REMARK VARCHAR(1000) NULL COMMENT '来源备注',
CUSTOMER_ID VARCHAR(64) NOT NULL COMMENT '客户编号',
RISK_DATE DATETIME(3) NOT NULL COMMENT '风险日期',
CUSTOMER_NAME VARCHAR(128) NOT NULL COMMENT '客户名称',
RISK_LEVEL VARCHAR(30) NOT NULL COMMENT '风险等级',
RISK_SCORE DECIMAL(10, 2) NOT NULL COMMENT '风险分值',
IS_FOCUS_CUSTOMER TINYINT(1) NOT NULL COMMENT '是否重点客户',
UNIQUE KEY UK_ANALYZE_DATA_430_ROW (MODEL_RUN_BATCH_ID, CUSTOMER_ID),
KEY IDX_ANALYZE_DATA_430_BATCH (MODEL_RUN_BATCH_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户洗钱风险模型结果';
CREATE TABLE IF NOT EXISTS ANALYZE_MODEL_DATA_MX_412 (
MODEL_ID VARCHAR(64) NOT NULL COMMENT '来源模型 ID',
MODEL_NAME VARCHAR(128) NOT NULL COMMENT '模型技术名称',
MODEL_RUN_BATCH_ID VARCHAR(64) NOT NULL COMMENT '来源跑批 ID',
MODEL_RUN_BATCH_DATE DATETIME(3) NOT NULL COMMENT '来源跑批时间',
REMARK VARCHAR(1000) NULL COMMENT '来源备注',
ALERT_ID VARCHAR(64) NOT NULL COMMENT '预警编号',
ACCOUNT_NAME VARCHAR(128) NOT NULL COMMENT '账户名称',
TRANSACTION_AMOUNT DECIMAL(18, 2) NOT NULL COMMENT '交易金额',
TRANSACTION_TIME DATETIME(3) NOT NULL COMMENT '交易时间',
ALERT_REASON VARCHAR(500) NOT NULL COMMENT '预警原因',
UNIQUE KEY UK_ANALYZE_DATA_412_ROW (MODEL_RUN_BATCH_ID, ALERT_ID),
KEY IDX_ANALYZE_DATA_412_BATCH (MODEL_RUN_BATCH_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常交易预警模型结果';
CREATE TABLE IF NOT EXISTS ANALYZE_MODEL_DATA_MX_411 (
MODEL_ID VARCHAR(64) NOT NULL COMMENT '来源模型 ID',
MODEL_NAME VARCHAR(128) NOT NULL COMMENT '模型技术名称',
MODEL_RUN_BATCH_ID VARCHAR(64) NOT NULL COMMENT '来源跑批 ID',
MODEL_RUN_BATCH_DATE DATETIME(3) NOT NULL COMMENT '来源跑批时间',
REMARK VARCHAR(1000) NULL COMMENT '来源备注',
INSTITUTION_CODE VARCHAR(64) NOT NULL COMMENT '机构编码',
REPORTING_PERIOD VARCHAR(30) NOT NULL COMMENT '报告期间',
INSTITUTION_NAME VARCHAR(128) NOT NULL COMMENT '机构名称',
CREDIT_LIMIT DECIMAL(18, 2) NOT NULL COMMENT '授信额度',
USED_AMOUNT DECIMAL(18, 2) NOT NULL COMMENT '已用额度',
OVER_LIMIT TINYINT(1) NOT NULL COMMENT '是否超限',
UNIQUE KEY UK_ANALYZE_DATA_411_ROW (MODEL_RUN_BATCH_ID, INSTITUTION_CODE),
KEY IDX_ANALYZE_DATA_411_BATCH (MODEL_RUN_BATCH_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授信敞口监测模型结果';
START TRANSACTION;
INSERT INTO ANALYZE_MXVIEW_INFO (
ID, MODEL_NAME, MODEL_ZH_NAME, MODEL_DATA_UPDATE_TIME, MODEL_STATE,
MODEL_APPLY_STATE, MODEL_RISK_LEVEL, MODEL_BUSINESS_TYPE,
MODEL_BUSINESS_TYPE_NAME
) VALUES
('430', 'AML_CUSTOMER_RISK', '客户洗钱风险模型', '2026-08-26 08:00:00.000', 'enabled', 'applied', 'high', '12', '风险分析'),
('412', 'TRANSACTION_ALERT', '异常交易预警模型', '2026-08-25 08:00:00.000', 'enabled', 'applied', 'medium', '12', '风险分析'),
('411', 'CREDIT_EXPOSURE', '授信敞口监测模型', '2026-08-24 08:00:00.000', 'enabled', 'applied', 'medium', '13', '合规分析')
ON DUPLICATE KEY UPDATE
MODEL_NAME = VALUES(MODEL_NAME),
MODEL_ZH_NAME = VALUES(MODEL_ZH_NAME),
MODEL_DATA_UPDATE_TIME = VALUES(MODEL_DATA_UPDATE_TIME),
MODEL_STATE = VALUES(MODEL_STATE),
MODEL_APPLY_STATE = VALUES(MODEL_APPLY_STATE),
MODEL_RISK_LEVEL = VALUES(MODEL_RISK_LEVEL),
MODEL_BUSINESS_TYPE = VALUES(MODEL_BUSINESS_TYPE),
MODEL_BUSINESS_TYPE_NAME = VALUES(MODEL_BUSINESS_TYPE_NAME);
INSERT INTO ANALYZE_MODEL_RUN_BATCH_INFO (
MODEL_RUN_BATCH_ID, MODEL_ID, MODEL_NAME, MODEL_RUN_BATCH_DATE,
MODEL_DATA_TABLE_NAME, REMARK, MODEL_DATA_CNT, UNCHECKED_CNT,
UNSUBMITTED_CNT, REVIEWED_CNT, RUN_BATCH_STATE, ERROR_MESSAGE
) VALUES
('690', '430', 'AML_CUSTOMER_RISK', '2026-08-26 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_430', '2026 年 8 月批次', 5, 5, 0, 0, 'success', NULL),
('652', '430', 'AML_CUSTOMER_RISK', '2026-07-31 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_430', '2026 年 7 月批次', 5, 3, 1, 1, 'success', NULL),
('590', '430', 'AML_CUSTOMER_RISK', '2025-12-31 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_430', '2025 年 12 月批次', 5, 0, 0, 5, 'success', NULL),
('670', '412', 'TRANSACTION_ALERT', '2026-08-25 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_412', '2026 年第 35 周', 7, 7, 0, 0, 'success', NULL),
('669', '412', 'TRANSACTION_ALERT', '2026-08-18 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_412', '2026 年第 34 周', 7, 4, 2, 1, 'success', NULL),
('570', '412', 'TRANSACTION_ALERT', '2025-12-29 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_412', '2025 年第 52 周', 7, 0, 0, 7, 'success', NULL),
('630', '411', 'CREDIT_EXPOSURE', '2026-08-24 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_411', '2026 年 8 月批次', 4, 4, 0, 0, 'success', NULL),
('629', '411', 'CREDIT_EXPOSURE', '2026-06-30 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_411', '2026 年第二季度', 4, 2, 1, 1, 'success', NULL),
('530', '411', 'CREDIT_EXPOSURE', '2025-12-31 08:00:00.000', 'ANALYZE_MODEL_DATA_MX_411', '2025 年第四季度', 4, 0, 0, 4, 'success', NULL)
ON DUPLICATE KEY UPDATE
MODEL_ID = VALUES(MODEL_ID),
MODEL_NAME = VALUES(MODEL_NAME),
MODEL_RUN_BATCH_DATE = VALUES(MODEL_RUN_BATCH_DATE),
MODEL_DATA_TABLE_NAME = VALUES(MODEL_DATA_TABLE_NAME),
REMARK = VALUES(REMARK),
MODEL_DATA_CNT = VALUES(MODEL_DATA_CNT),
UNCHECKED_CNT = VALUES(UNCHECKED_CNT),
UNSUBMITTED_CNT = VALUES(UNSUBMITTED_CNT),
REVIEWED_CNT = VALUES(REVIEWED_CNT),
RUN_BATCH_STATE = VALUES(RUN_BATCH_STATE),
ERROR_MESSAGE = VALUES(ERROR_MESSAGE);
INSERT INTO ANALYZE_MODEL_DATA_MX_430 (
MODEL_ID, MODEL_NAME, MODEL_RUN_BATCH_ID, MODEL_RUN_BATCH_DATE, REMARK,
CUSTOMER_ID, RISK_DATE, CUSTOMER_NAME, RISK_LEVEL, RISK_SCORE,
IS_FOCUS_CUSTOMER
) VALUES
('430', 'AML_CUSTOMER_RISK', '690', '2026-08-26 08:00:00.000', NULL, 'C0001', '2026-08-03 00:00:00.000', '华远实业', '高', 93.00, 1),
('430', 'AML_CUSTOMER_RISK', '690', '2026-08-26 08:00:00.000', NULL, 'C0002', '2026-08-04 00:00:00.000', '海川商贸', '高', 84.00, 1),
('430', 'AML_CUSTOMER_RISK', '690', '2026-08-26 08:00:00.000', NULL, 'C0003', '2026-08-05 00:00:00.000', '恒信科技', '中', 75.00, 0),
('430', 'AML_CUSTOMER_RISK', '690', '2026-08-26 08:00:00.000', NULL, 'C0004', '2026-08-06 00:00:00.000', '嘉禾供应链', '中', 66.00, 0),
('430', 'AML_CUSTOMER_RISK', '690', '2026-08-26 08:00:00.000', NULL, 'C0005', '2026-08-07 00:00:00.000', '远景投资', '低', 57.00, 0),
('430', 'AML_CUSTOMER_RISK', '652', '2026-07-31 08:00:00.000', NULL, 'C0001', '2026-07-03 00:00:00.000', '华远实业', '高', 91.00, 1),
('430', 'AML_CUSTOMER_RISK', '652', '2026-07-31 08:00:00.000', NULL, 'C0002', '2026-07-04 00:00:00.000', '海川商贸', '高', 82.00, 1),
('430', 'AML_CUSTOMER_RISK', '652', '2026-07-31 08:00:00.000', NULL, 'C0003', '2026-07-05 00:00:00.000', '恒信科技', '中', 73.00, 0),
('430', 'AML_CUSTOMER_RISK', '652', '2026-07-31 08:00:00.000', NULL, 'C0004', '2026-07-06 00:00:00.000', '嘉禾供应链', '中', 64.00, 0),
('430', 'AML_CUSTOMER_RISK', '652', '2026-07-31 08:00:00.000', NULL, 'C0005', '2026-07-07 00:00:00.000', '远景投资', '低', 55.00, 0),
('430', 'AML_CUSTOMER_RISK', '590', '2025-12-31 08:00:00.000', NULL, 'C0001', '2025-12-03 00:00:00.000', '华远实业', '高', 89.00, 1),
('430', 'AML_CUSTOMER_RISK', '590', '2025-12-31 08:00:00.000', NULL, 'C0002', '2025-12-04 00:00:00.000', '海川商贸', '高', 80.00, 1),
('430', 'AML_CUSTOMER_RISK', '590', '2025-12-31 08:00:00.000', NULL, 'C0003', '2025-12-05 00:00:00.000', '恒信科技', '中', 71.00, 0),
('430', 'AML_CUSTOMER_RISK', '590', '2025-12-31 08:00:00.000', NULL, 'C0004', '2025-12-06 00:00:00.000', '嘉禾供应链', '中', 62.00, 0),
('430', 'AML_CUSTOMER_RISK', '590', '2025-12-31 08:00:00.000', NULL, 'C0005', '2025-12-07 00:00:00.000', '远景投资', '低', 53.00, 0)
ON DUPLICATE KEY UPDATE
MODEL_ID = VALUES(MODEL_ID), MODEL_NAME = VALUES(MODEL_NAME),
MODEL_RUN_BATCH_DATE = VALUES(MODEL_RUN_BATCH_DATE), REMARK = VALUES(REMARK),
RISK_DATE = VALUES(RISK_DATE), CUSTOMER_NAME = VALUES(CUSTOMER_NAME),
RISK_LEVEL = VALUES(RISK_LEVEL), RISK_SCORE = VALUES(RISK_SCORE),
IS_FOCUS_CUSTOMER = VALUES(IS_FOCUS_CUSTOMER);
INSERT INTO ANALYZE_MODEL_DATA_MX_412 (
MODEL_ID, MODEL_NAME, MODEL_RUN_BATCH_ID, MODEL_RUN_BATCH_DATE, REMARK,
ALERT_ID, ACCOUNT_NAME, TRANSACTION_AMOUNT, TRANSACTION_TIME, ALERT_REASON
) VALUES
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35001', '监测账户 1', 180000.00, '2026-08-18 00:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35002', '监测账户 2', 253500.00, '2026-08-19 01:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35003', '监测账户 3', 327000.00, '2026-08-20 02:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35004', '监测账户 4', 400500.00, '2026-08-21 03:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35005', '监测账户 5', 474000.00, '2026-08-22 04:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35006', '监测账户 6', 547500.00, '2026-08-23 05:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '670', '2026-08-25 08:00:00.000', NULL, 'AL35007', '监测账户 7', 621000.00, '2026-08-24 06:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34001', '监测账户 1', 172000.00, '2026-08-11 00:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34002', '监测账户 2', 245500.00, '2026-08-12 01:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34003', '监测账户 3', 319000.00, '2026-08-13 02:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34004', '监测账户 4', 392500.00, '2026-08-14 03:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34005', '监测账户 5', 466000.00, '2026-08-15 04:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34006', '监测账户 6', 539500.00, '2026-08-16 05:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '669', '2026-08-18 08:00:00.000', NULL, 'AL34007', '监测账户 7', 613000.00, '2026-08-17 06:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52001', '监测账户 1', 165000.00, '2025-12-22 00:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52002', '监测账户 2', 238500.00, '2025-12-23 01:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52003', '监测账户 3', 312000.00, '2025-12-24 02:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52004', '监测账户 4', 385500.00, '2025-12-25 03:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52005', '监测账户 5', 459000.00, '2025-12-26 04:30:00.000', '短期高频大额交易'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52006', '监测账户 6', 532500.00, '2025-12-27 05:30:00.000', '交易对手集中度异常'),
('412', 'TRANSACTION_ALERT', '570', '2025-12-29 08:00:00.000', NULL, 'AL52007', '监测账户 7', 606000.00, '2025-12-28 06:30:00.000', '短期高频大额交易')
ON DUPLICATE KEY UPDATE
MODEL_ID = VALUES(MODEL_ID), MODEL_NAME = VALUES(MODEL_NAME),
MODEL_RUN_BATCH_DATE = VALUES(MODEL_RUN_BATCH_DATE), REMARK = VALUES(REMARK),
ACCOUNT_NAME = VALUES(ACCOUNT_NAME), TRANSACTION_AMOUNT = VALUES(TRANSACTION_AMOUNT),
TRANSACTION_TIME = VALUES(TRANSACTION_TIME), ALERT_REASON = VALUES(ALERT_REASON);
INSERT INTO ANALYZE_MODEL_DATA_MX_411 (
MODEL_ID, MODEL_NAME, MODEL_RUN_BATCH_ID, MODEL_RUN_BATCH_DATE, REMARK,
INSTITUTION_CODE, REPORTING_PERIOD, INSTITUTION_NAME, CREDIT_LIMIT,
USED_AMOUNT, OVER_LIMIT
) VALUES
('411', 'CREDIT_EXPOSURE', '630', '2026-08-24 08:00:00.000', NULL, 'ORG1', '2026-08', '总行营业部', 12000000.00, 8640000.00, 0),
('411', 'CREDIT_EXPOSURE', '630', '2026-08-24 08:00:00.000', NULL, 'ORG2', '2026-08', '华东分行', 15500000.00, 12710000.00, 0),
('411', 'CREDIT_EXPOSURE', '630', '2026-08-24 08:00:00.000', NULL, 'ORG3', '2026-08', '华南分行', 19000000.00, 17480000.00, 0),
('411', 'CREDIT_EXPOSURE', '630', '2026-08-24 08:00:00.000', NULL, 'ORG4', '2026-08', '西部中心', 22500000.00, 22950000.00, 1),
('411', 'CREDIT_EXPOSURE', '629', '2026-06-30 08:00:00.000', NULL, 'ORG1', '2026-Q2', '总行营业部', 11280000.00, 8121600.00, 0),
('411', 'CREDIT_EXPOSURE', '629', '2026-06-30 08:00:00.000', NULL, 'ORG2', '2026-Q2', '华东分行', 14570000.00, 11947400.00, 0),
('411', 'CREDIT_EXPOSURE', '629', '2026-06-30 08:00:00.000', NULL, 'ORG3', '2026-Q2', '华南分行', 17860000.00, 16431200.00, 0),
('411', 'CREDIT_EXPOSURE', '629', '2026-06-30 08:00:00.000', NULL, 'ORG4', '2026-Q2', '西部中心', 21150000.00, 21573000.00, 1),
('411', 'CREDIT_EXPOSURE', '530', '2025-12-31 08:00:00.000', NULL, 'ORG1', '2025-Q4', '总行营业部', 10800000.00, 7560000.00, 0),
('411', 'CREDIT_EXPOSURE', '530', '2025-12-31 08:00:00.000', NULL, 'ORG2', '2025-Q4', '华东分行', 13950000.00, 11160000.00, 0),
('411', 'CREDIT_EXPOSURE', '530', '2025-12-31 08:00:00.000', NULL, 'ORG3', '2025-Q4', '华南分行', 17100000.00, 15390000.00, 0),
('411', 'CREDIT_EXPOSURE', '530', '2025-12-31 08:00:00.000', NULL, 'ORG4', '2025-Q4', '西部中心', 20250000.00, 20452500.00, 1)
ON DUPLICATE KEY UPDATE
MODEL_ID = VALUES(MODEL_ID), MODEL_NAME = VALUES(MODEL_NAME),
MODEL_RUN_BATCH_DATE = VALUES(MODEL_RUN_BATCH_DATE), REMARK = VALUES(REMARK),
REPORTING_PERIOD = VALUES(REPORTING_PERIOD), INSTITUTION_NAME = VALUES(INSTITUTION_NAME),
CREDIT_LIMIT = VALUES(CREDIT_LIMIT), USED_AMOUNT = VALUES(USED_AMOUNT),
OVER_LIMIT = VALUES(OVER_LIMIT);
COMMIT;
......@@ -47,6 +47,27 @@ function canRecipientEdit(record, currentUserId) {
);
}
function canFirstApprove(record, currentUserId) {
return Boolean(
record?.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending',
);
}
function canFinalApprove(record, currentUserId) {
return Boolean(
record?.finalApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending',
);
}
function canApproveCurrentStep(record, currentUserId) {
return canFirstApprove(record, currentUserId) || canFinalApprove(record, currentUserId);
}
function getForwardEditorStatusLabel(record) {
return isReturnedForEditing(record) ? '待重新填写' : getEditorStatusLabel(record?.editorStatus);
}
......@@ -125,15 +146,7 @@ export default function ForwardApprovals() {
}
if (activeFilter === 'pending') {
return (
(record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending') ||
(record.finalApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending')
);
return canApproveCurrentStep(record, currentUserId);
}
return (
......@@ -226,7 +239,8 @@ export default function ForwardApprovals() {
}
async function handleSaveEditableRecords() {
if (!editingRecord) {
if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return;
}
......@@ -248,7 +262,8 @@ export default function ForwardApprovals() {
}
async function handleCompleteEditing() {
if (!editingRecord) {
if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return;
}
......@@ -292,30 +307,21 @@ export default function ForwardApprovals() {
}
async function handleApprove(record) {
if (
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
if (canFirstApprove(record, currentUserId)) {
await handleUpdateRecord(record, { approval_status: 'approved' });
return;
}
if (
record.finalApprover === currentUserId &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending'
) {
if (canFinalApprove(record, currentUserId)) {
await handleUpdateRecord(record, { final_approval_status: 'approved' });
return;
}
setError('当前用户无权审批该环节,或审批状态已经变化。');
}
async function handleReject(record) {
if (
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
if (canFirstApprove(record, currentUserId)) {
await handleUpdateRecord(record, {
editor_status: 'editing',
completed_by_id: '',
......@@ -327,11 +333,7 @@ export default function ForwardApprovals() {
return;
}
if (
record.finalApprover === currentUserId &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending'
) {
if (canFinalApprove(record, currentUserId)) {
await handleUpdateRecord(record, {
editor_status: 'editing',
completed_by_id: '',
......@@ -339,7 +341,10 @@ export default function ForwardApprovals() {
completed_at: '',
final_approval_status: 'rejected',
});
return;
}
setError('当前用户无权审批该环节,或审批状态已经变化。');
}
const canCurrentUserEdit = Boolean(
......@@ -481,15 +486,7 @@ export default function ForwardApprovals() {
<Button
isDisabled={
saving ||
!(
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
!canApproveCurrentStep(activeRecord, currentUserId)
}
variant="danger-soft"
onPress={() => handleReject(activeRecord)}
......@@ -499,15 +496,7 @@ export default function ForwardApprovals() {
<Button
isDisabled={
saving ||
!(
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
!canApproveCurrentStep(activeRecord, currentUserId)
}
variant="primary"
onPress={() => handleApprove(activeRecord)}
......
import {
connectorExecute,
connectorInspect,
getSource,
} from '@/services/DataService';
const DEFAULT_CONNECTOR_NAME = typeof MODEL_MONITOR_CONNECTOR_NAME !== 'undefined' && MODEL_MONITOR_CONNECTOR_NAME
? MODEL_MONITOR_CONNECTOR_NAME
: 'MODEL_MONITOR_ORACLE';
const RESERVED_SOURCE_COLUMNS = new Set([
'MODEL_ID',
'MODEL_NAME',
'MODEL_RUN_BATCH_ID',
'MODEL_RUN_BATCH_DATE',
'REMARK',
]);
let connectorConfigCache = null;
function extractArray(response) {
const candidates = [
response,
response?.rows,
response?.data,
response?.result,
response?.records,
response?.data?.rows,
response?.data?.records,
response?.result?.rows,
response?.result?.records,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return [];
}
function extractObject(response) {
return response?.data || response?.result || response;
}
function escapeSql(value) {
return String(value ?? '').replace(/'/g, "''");
}
function isMysqlClient(settings) {
return ['mysql', 'mysql2'].includes(String(settings?.client ?? '').toLowerCase());
}
function quoteIdentifier(identifier, settings) {
const value = String(identifier ?? '');
if (isMysqlClient(settings)) {
return `\`${value.replace(/`/g, '``')}\``;
}
return `"${value.replace(/"/g, '""')}"`;
}
function quoteQualifiedIdentifier(identifier, settings) {
return String(identifier ?? '')
.split('.')
.map((part) => quoteIdentifier(part, settings))
.join('.');
}
function normalizeName(value, index, usedNames) {
let name = String(value ?? '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!name) name = `field_${index + 1}`;
if (!/^[a-z]/.test(name)) name = `f_${name}`;
let suffix = 1;
while (usedNames.has(name)) {
suffix += 1;
name = `${name}_${suffix}`;
}
usedNames.add(name);
return name;
}
function mapColumnType(rawType) {
const value = String(rawType ?? '').toLowerCase();
if (/(date|timestamp|time)/.test(value)) return 'date';
if (/(number|decimal|numeric|int|integer|float|double|real|binary_float|binary_double)/.test(value)) {
return 'number';
}
if (/(bool|boolean)/.test(value)) return 'boolean';
if (/(json|object|struct|udt|collection)/.test(value)) return 'object';
return 'text';
}
function normalizeColumns(columns = []) {
const usedNames = new Set();
return columns.map((column, index) => {
const rawName = String(
column.name
|| column.column_name
|| column.COLUMN_NAME
|| column.alias
|| column.ALIAS
|| '',
).trim();
const name = normalizeName(rawName, index, usedNames);
const label = String(
column.label
|| column.comment
|| column.column_comment
|| column.COLUMN_COMMENT
|| rawName
|| name,
).trim();
return {
sourceName: rawName || name,
name,
label,
type: mapColumnType(column.data_type || column.DATA_TYPE || column.type || column.TYPE),
};
});
}
async function resolveConnectorConfig() {
if (connectorConfigCache) return connectorConfigCache;
const source = await getSource(DEFAULT_CONNECTOR_NAME);
if (!source) {
throw new Error(`未找到固定数据源“${DEFAULT_CONNECTOR_NAME}”。`);
}
if (source.status && source.status !== 'active') {
throw new Error(`固定数据源“${DEFAULT_CONNECTOR_NAME}”未启用。`);
}
if (!source.settings) {
throw new Error(`固定数据源“${DEFAULT_CONNECTOR_NAME}”缺少连接配置。`);
}
connectorConfigCache = {
name: source.name || DEFAULT_CONNECTOR_NAME,
settings: source.settings,
};
return connectorConfigCache;
}
function normalizeModelRow(row = {}) {
return {
source_model_id: String(row.source_model_id ?? row.SOURCE_MODEL_ID ?? row.id ?? row.ID ?? '').trim(),
source_model_name: String(row.source_model_name ?? row.SOURCE_MODEL_NAME ?? row.model_name ?? row.MODEL_NAME ?? '').trim(),
model_code: String(row.model_code ?? row.MODEL_CODE ?? row.source_model_name ?? row.SOURCE_MODEL_NAME ?? row.MODEL_NAME ?? '').trim(),
model_name: String(row.model_name ?? row.MODEL_NAME ?? row.source_model_name ?? row.SOURCE_MODEL_NAME ?? '').trim(),
source_model_updated_at: row.source_model_updated_at ?? row.SOURCE_MODEL_UPDATED_AT ?? '',
model_state: row.model_state ?? row.MODEL_STATE ?? '',
model_apply_state: row.model_apply_state ?? row.MODEL_APPLY_STATE ?? '',
model_risk_level: row.model_risk_level ?? row.MODEL_RISK_LEVEL ?? '',
model_business_type: row.model_business_type ?? row.MODEL_BUSINESS_TYPE ?? '',
model_business_type_name: row.model_business_type_name ?? row.MODEL_BUSINESS_TYPE_NAME ?? '',
};
}
function normalizeBatchRow(row = {}) {
const sourceBatchDate = row.source_batch_date ?? row.SOURCE_BATCH_DATE ?? '';
const batchCode = String(row.batch_code ?? row.BATCH_CODE ?? row.model_run_batch_id ?? row.MODEL_RUN_BATCH_ID ?? '').trim();
return {
source_model_id: String(row.source_model_id ?? row.SOURCE_MODEL_ID ?? row.model_id ?? row.MODEL_ID ?? '').trim(),
source_model_name: String(row.source_model_name ?? row.SOURCE_MODEL_NAME ?? row.model_name ?? row.MODEL_NAME ?? '').trim(),
batch_code: batchCode,
batch_name: String(sourceBatchDate || batchCode || '').trim() || batchCode,
source_batch_date: sourceBatchDate,
data_table_name: String(row.data_table_name ?? row.DATA_TABLE_NAME ?? row.model_data_table_name ?? row.MODEL_DATA_TABLE_NAME ?? '').trim().toUpperCase(),
remark: String(row.remark ?? row.REMARK ?? '').trim(),
source_model_data_count: Number(row.source_model_data_count ?? row.SOURCE_MODEL_DATA_COUNT ?? row.model_data_cnt ?? row.MODEL_DATA_CNT ?? 0),
source_unchecked_count: Number(row.source_unchecked_count ?? row.SOURCE_UNCHECKED_COUNT ?? row.unchecked_cnt ?? row.UNCHECKED_CNT ?? 0),
source_unsubmitted_count: Number(row.source_unsubmitted_count ?? row.SOURCE_UNSUBMITTED_COUNT ?? row.unsubmitted_cnt ?? row.UNSUBMITTED_CNT ?? 0),
source_reviewed_count: Number(row.source_reviewed_count ?? row.SOURCE_REVIEWED_COUNT ?? row.reviewed_cnt ?? row.REVIEWED_CNT ?? 0),
};
}
function normalizeInspectColumns(response) {
const data = extractObject(response);
const columns = Array.isArray(data?.columns) ? data.columns : Array.isArray(data) ? data : [];
return normalizeColumns(columns)
.filter((column) => !RESERVED_SOURCE_COLUMNS.has(String(column.sourceName || '').toUpperCase()));
}
function buildSelectList(columns, settings) {
return columns
.map((column) => `${quoteIdentifier(column.sourceName, settings)} as ${quoteIdentifier(column.name, settings)}`)
.join(', ');
}
function extractRows(response) {
const rows = extractArray(response);
if (rows.length > 0) return rows;
if (Array.isArray(response?.[0])) return response[0];
return [];
}
function readRowValue(row, column) {
const candidates = [
column.name,
column.name.toUpperCase(),
column.sourceName,
column.sourceName.toUpperCase(),
];
for (const key of candidates) {
if (key && row && Object.prototype.hasOwnProperty.call(row, key)) {
return row[key];
}
}
return null;
}
export async function listSourceModels() {
const { settings } = await resolveConnectorConfig();
const response = await connectorExecute({
config: settings,
query: `
select
ID as "source_model_id",
MODEL_NAME as "source_model_name",
MODEL_NAME as "model_code",
COALESCE(MODEL_ZH_NAME, MODEL_NAME) as "model_name",
MODEL_DATA_UPDATE_TIME as "source_model_updated_at",
MODEL_STATE as "model_state",
MODEL_APPLY_STATE as "model_apply_state",
MODEL_RISK_LEVEL as "model_risk_level",
MODEL_BUSINESS_TYPE as "model_business_type",
MODEL_BUSINESS_TYPE_NAME as "model_business_type_name"
from ANALYZE_MXVIEW_INFO
order by ID desc
`,
});
return extractRows(response).map(normalizeModelRow);
}
export async function listSourceBatches(sourceModel) {
const { settings } = await resolveConnectorConfig();
const sourceModelId = String(sourceModel?.source_model_id ?? sourceModel?.id ?? '').trim();
const sourceModelName = String(sourceModel?.source_model_name ?? sourceModel?.model_code ?? '').trim();
if (!sourceModelId) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.ID。');
if (!sourceModelName) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.MODEL_NAME。');
const response = await connectorExecute({
config: settings,
query: `
select
MODEL_ID as "source_model_id",
MODEL_NAME as "source_model_name",
MODEL_RUN_BATCH_ID as "batch_code",
MODEL_RUN_BATCH_DATE as "source_batch_date",
MODEL_DATA_TABLE_NAME as "data_table_name",
REMARK as "remark",
MODEL_DATA_CNT as "source_model_data_count",
UNCHECKED_CNT as "source_unchecked_count",
UNSUBMITTED_CNT as "source_unsubmitted_count",
REVIEWED_CNT as "source_reviewed_count"
from ANALYZE_MODEL_RUN_BATCH_INFO
where MODEL_ID = '${escapeSql(sourceModelId)}'
and MODEL_NAME = '${escapeSql(sourceModelName)}'
order by MODEL_RUN_BATCH_DATE desc, MODEL_RUN_BATCH_ID desc
`,
});
return extractRows(response).map(normalizeBatchRow);
}
export async function loadSourceBatchData(sourceModel, sourceBatch) {
const { settings } = await resolveConnectorConfig();
const sourceModelId = String(sourceModel?.source_model_id ?? sourceModel?.id ?? '').trim();
const sourceModelName = String(sourceModel?.source_model_name ?? sourceModel?.model_code ?? '').trim();
const batchCode = String(sourceBatch?.batch_code ?? '').trim();
const tableName = String(sourceBatch?.data_table_name ?? '').trim().toUpperCase();
if (!sourceModelId) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.ID。');
if (!sourceModelName) throw new Error('来源模型缺少 ANALYZE_MXVIEW_INFO.MODEL_NAME。');
if (!batchCode) throw new Error('来源批次缺少 MODEL_RUN_BATCH_ID。');
if (!tableName) throw new Error('来源批次缺少 MODEL_DATA_TABLE_NAME。');
if (!/^[A-Z][A-Z0-9_$#]*(\.[A-Z][A-Z0-9_$#]*)?$/.test(tableName)) {
throw new Error('来源批次的数据表名为空或不符合来源数据库标识符规范。');
}
const inspectResponse = await connectorInspect({
config: settings,
table: tableName,
});
const columns = normalizeInspectColumns(inspectResponse);
if (!columns.length) {
throw new Error(`数据表“${tableName}”没有可用字段定义。`);
}
const selectColumns = buildSelectList(columns, settings);
const response = await connectorExecute({
config: settings,
query: `
select ${selectColumns}
from ${quoteQualifiedIdentifier(tableName, settings)}
where MODEL_RUN_BATCH_ID = '${escapeSql(batchCode)}'
`,
});
const rows = extractRows(response).map((row) => Object.fromEntries(
columns.map((column) => {
return [column.name, readRowValue(row, column)];
}),
));
return {
columns: columns.map(({ sourceName, ...column }) => column),
rows,
};
}
export { DEFAULT_CONNECTOR_NAME as MODEL_MONITOR_CONNECTOR_NAME };
......@@ -30,7 +30,7 @@
## 3. 外部数据源契约
当前外部来源仍使用 Mock 适配器,但返回结构已映射到 `ANALYZE_MXVIEW_INFO` 和 `ANALYZE_MODEL_RUN_BATCH_INFO`。UI 仅依赖以下方法,真实 Oracle 适配器接入时不得修改页面调用:
生产环境的外部来源通过通用 Connector 接口读取固定 Oracle 数据源,开发联调允许使用遵循同一表结构的 MySQL 5.7+ 测试源;适配器根据 Connector 的 `settings.client` 使用 Oracle 双引号或 MySQL 反引号引用动态表与字段。同步后的模型监控数据写入平台普通 Subject。模型监控页面不提供数据源选择器,来源 connector 名称由构建常量 `MODEL_MONITOR_CONNECTOR_NAME` 提供。UI 仅依赖以下方法,页面调用保持不变:
```ts
listSourceModels(): Promise<Array<{
......@@ -56,11 +56,13 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
}>;
```
来源映射规则:模型稳定键为 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`;批次稳定键为 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,直接映射到本地 `batch_code`;结果表来自 `MODEL_DATA_TABLE_NAME`。结果表查询必须先对白名单表名做校验,再使用绑定参数按 `MODEL_RUN_BATCH_ID` 过滤,不能整表读取。
来源映射规则:模型稳定键为 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`;批次稳定键为 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,直接映射到本地 `batch_code`;结果表来自 `MODEL_DATA_TABLE_NAME`。结果表查询必须先对白名单表名做校验,再对批次 ID 做字符串转义并按 `MODEL_RUN_BATCH_ID` 过滤,不能整表读取。
`ANALYZE_MODEL_DATA_MX_*` 结果行不要求业务主键。每个批次只允许同步一次,结果行按来源读取顺序记录 `source_row_number` 并纯新增,不进行去重或更新。
未来真实后台接口到位时只替换适配器,不修改页面和本地持久化服务。仓库目前没有 Oracle Connector 的调用协议,接入前必须补充接口 URL、请求体、分页和字段类型响应定义。
MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` 初始化。脚本只创建不存在的来源表,并按稳定键更新 3 个模型、9 个批次和 48 条结果样例;使用测试源启动前端时,必须把 `MODEL_MONITOR_CONNECTOR_NAME` 配置为对应的 MySQL Connector。该环境只验证通用同步流程,不替代 Oracle 驱动和方言的集成验收。
未来若固定数据源配置变化,只替换构建常量和适配器实现,不修改页面和本地持久化服务。
## 4. 核心流程
......@@ -84,8 +86,8 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- 来源声明数量与实际读取数量检查
- 字段别名、重复字段和类型检查
- 所有数值、日期、布尔和对象字段的规范化检查
8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成稳定动态数据 Subject 名称。
9. 自动创建或扩展模型数据 Subject。只允许增加字段;类型冲突立即停止。
8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成以 `model_monitor_data_` 开头的稳定动态数据 Subject 名称。
9. 自动创建或扩展普通模型数据 Subject。只允许增加字段,类型冲突立即停止。
10. 按 `MODEL_RUN_BATCH_ID` 查找批次;任何状态的本地批次一旦存在都拒绝重复同步,不再读取来源结果表。
11. 为来源行生成从 1 开始的 `source_row_number`。
12. 每 20 条分块纯新增写入,不查询已有结果行,也不执行更新;数据 Record name 由 `MODEL_RUN_BATCH_ID + source_row_number` 生成。
......@@ -98,7 +100,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- `partial`:部分行写入失败,成功数据保留。
- `failed`:校验、对象创建或同步过程失败。
- `partial/failed` 批次保留现场供排查,同样不允许重试,避免重复写入。
- 批次保留错误摘要、新增数、失败数、来源行数和本地总数;更新数固定为 0。
- 批次保留错误摘要、新增数、失败数、来源行数和本地总数;更新数固定为 0。模型监控 Record 写入失败时保留已成功写入的数据和批次状态。
### 4.4 模型分组
......@@ -142,6 +144,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。
7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。
8. 终审通过后状态为已最终审批,流程结束。
9. 接收人填写、完成填写、一级审批和终审在前端同时执行按钮禁用与提交事件校验;用户身份或流程状态不满足时不发送更新请求,并提示状态已经变化或当前用户无权操作。
## 5. 页面状态与异常处理
......
......@@ -16,7 +16,7 @@ import {
listSourceBatches,
listSourceModels,
loadSourceBatchData,
} from './ModelMonitorMockService';
} from './ModelMonitorConnectorService';
export const MODEL_GROUP_SUBJECT = 'model_monitor_group';
export const MODEL_SUBJECT = 'model_monitor_model';
......@@ -302,6 +302,7 @@ function normalizeSubject(subject = {}) {
id: candidate?._id || candidate?.id || candidate?.name || '',
name: candidate?.name || candidate?.title || '',
title: candidate?.title || candidate?.name || '',
type: candidate?.type || 'normal',
fields: (Array.isArray(fields) ? fields : []).map(normalizeField),
};
}
......@@ -495,7 +496,7 @@ function validateSourceSelection(sourceModel = {}, sourceBatch = {}) {
throw new Error('来源批次的 MODEL_NAME 与所选模型名称不一致。');
}
if (!/^[A-Z][A-Z0-9_$#]*(\.[A-Z][A-Z0-9_$#]*)?$/.test(sourceDataTableName)) {
throw new Error('来源批次的数据表名为空或不符合 Oracle 非引号标识符规范。');
throw new Error('来源批次的数据表名为空或不符合来源数据库标识符规范。');
}
return {
......@@ -570,6 +571,11 @@ async function ensureSubject(definition) {
return normalizeSubject(await createSubject(toSubjectPayload(definition)));
}
const expectedType = String(definition.type || 'normal').toLowerCase();
if (String(subject.type || 'normal').toLowerCase() !== expectedType) {
throw new Error(`对象“${definition.name}”当前类型为 ${subject.type},期望类型为 ${expectedType},已停止初始化。`);
}
const existingFields = new Map(subject.fields.map((field) => [field.alias, field]));
for (const field of definition.fields) {
const existing = existingFields.get(field.name);
......
......@@ -1339,6 +1339,19 @@ function connectorTest(data) {
});
}
function querySources(query) {
return request('/connector/manage/query', {
method: 'post',
data: query,
});
}
function getSource(name) {
return request(`/connector/manage/${name}`, {
method: 'get',
});
}
function connectorList(data) {
return request(`/connector/list`, {
method: 'post',
......@@ -2080,6 +2093,7 @@ export {
connectorExecute,
connectorInspect,
connectorList,
getSource,
connectorQuery,
connectorQueryExecute,
connectorSelect,
......@@ -2243,6 +2257,7 @@ export {
// Notice
queryNotice,
queryPlaywrightSession,
querySources,
queryRecords,
queryRemovedRecord,
//rule
......
......@@ -44,7 +44,7 @@
}
```
开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。正式环境建议由部署流程预建固定 Subject。
开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。当前均使用后端默认的 `normal` 类型,不传 `type` 和 `external`;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。
### 2.3 增加 Subject 字段
......@@ -158,7 +158,7 @@
## 5. 外部来源适配契约
当前仓库尚未提供 Oracle Connector 的请求体和响应体协议,因此仍由 `ModelMonitorMockService` 提供数据,但 Mock 已按 `ANALYZE_MXVIEW_INFO` 和 `ANALYZE_MODEL_RUN_BATCH_INFO` 的字段语义返回。真实适配器接入后必须保持以下签名,页面和 Record 同步逻辑不直接拼 SQL:
当前仓库通过通用 Connector 接口读取生产 Oracle 数据源;开发联调允许接入遵循相同来源表结构的 MySQL 5.7+ 测试数据源。同步后的模型、批次和动态数据写入平台普通 Subject。模型监控页面不直接拼 SQL,也不让用户在页面上选择数据源;来源 connector 名称由构建常量 `MODEL_MONITOR_CONNECTOR_NAME` 提供。前端通过 `/connector/manage/:name` 读取来源连接配置,再用 `/connector/execute` 与 `/connector/inspect` 读取模型、批次和批次数据;动态标识符必须根据 Connector 客户端使用对应方言引用。接口适配层必须保持以下签名:
```ts
listSourceModels(): Promise<Array<{
......@@ -204,13 +204,14 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- `batch_code` 直接保存 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,不再增加重复的来源跑批 ID 字段。
- 批次的 `MODEL_NAME + MODEL_ID` 必须等于所选模型主键。
- `MODEL_DATA_TABLE_NAME` 只能来自批次表,并必须匹配 Oracle 非引号标识符白名单;不得接受页面输入的任意表名。
- 动态表名不能作为绑定参数,后端必须先校验白名单再拼接;批次 ID 必须使用绑定参数查询。
- 动态表名不能作为绑定参数,必须先校验白名单再拼接;批次 ID 需要在适配层做字符串转义后再参与查询。
- 结果表必须按 `MODEL_RUN_BATCH_ID` 过滤,禁止只按表名读取整张模型结果表。
- `source_model_data_count` 存在时必须与实际读取行数一致,否则停止写入。
- 本地一旦存在相同 `batch_code` 的批次 Record,无论状态为何,界面和 service 层都必须拒绝再次同步。
- `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。
- 日期在写入 Record 前转换为 ISO 8601。
- 非法字段值或字段类型冲突必须在写入前报错。
- 所有模型监控 Subject 当前均使用默认 `normal` 类型,数据写入平台内部存储;对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。
- 结果行只执行新增,不读取已有结果行,也不执行更新;`source_row_number` 保存本次来源读取顺序。
- 模型、批次和数据行 Record name 分别由 `MODEL_NAME + ID`、`MODEL_RUN_BATCH_ID`、`MODEL_RUN_BATCH_ID + source_row_number` 生成稳定哈希。
......
......@@ -15,6 +15,8 @@
旧 `analysis_view_config`、`analysis_view_group`、`analysis_forward_record`、`analysis_forward_record_item`、`analysis_dashboard_board` 仅作为历史数据保留,新界面不得读取或写入。
当前阶段所有模型监控 Subject 均使用默认 `normal` 类型,Record 保存在平台内部存储中;MySQL 仅作为模型来源数据源。对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。
## 1. 统一 Record 协议
```ts
......@@ -147,7 +149,7 @@ type Record = {
## 3. 动态模型数据 Subject
每个模型首次同步时按 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID` 自动创建稳定的动态数据 Subject。后续批次继续使用同一 Subject。
每个模型首次同步时按 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID` 自动创建稳定的 `model_monitor_data_<stable_id>` 普通动态数据 Subject,后续批次继续使用同一 Subject。
固定系统字段:
......@@ -170,6 +172,6 @@ type Record = {
- 模型和批次不提供删除。
- 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。
- `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。
- 固定 Subject 在开发阶段由前端检查并创建;正式环境应由部署流程预建。
- 固定 Subject 在开发阶段由前端检查并创建为普通对象;正式环境应由部署流程预建。
- 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。
- Subject/Record 写入失败必须明确报错,不得回退 localStorage。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment