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',
},
});
This diff is collapsed.
......@@ -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)}
......
This diff is collapsed.
......@@ -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