Commit 5b9d1940 authored by drigle's avatar drigle

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

parent 41e4f24d
...@@ -133,5 +133,6 @@ export default defineConfig({ ...@@ -133,5 +133,6 @@ export default defineConfig({
define: { define: {
STREAMS_API_URL: '/api', STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser', 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) { ...@@ -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) { function getForwardEditorStatusLabel(record) {
return isReturnedForEditing(record) ? '待重新填写' : getEditorStatusLabel(record?.editorStatus); return isReturnedForEditing(record) ? '待重新填写' : getEditorStatusLabel(record?.editorStatus);
} }
...@@ -125,15 +146,7 @@ export default function ForwardApprovals() { ...@@ -125,15 +146,7 @@ export default function ForwardApprovals() {
} }
if (activeFilter === 'pending') { if (activeFilter === 'pending') {
return ( return canApproveCurrentStep(record, currentUserId);
(record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending') ||
(record.finalApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending')
);
} }
return ( return (
...@@ -226,7 +239,8 @@ export default function ForwardApprovals() { ...@@ -226,7 +239,8 @@ export default function ForwardApprovals() {
} }
async function handleSaveEditableRecords() { async function handleSaveEditableRecords() {
if (!editingRecord) { if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return; return;
} }
...@@ -248,7 +262,8 @@ export default function ForwardApprovals() { ...@@ -248,7 +262,8 @@ export default function ForwardApprovals() {
} }
async function handleCompleteEditing() { async function handleCompleteEditing() {
if (!editingRecord) { if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return; return;
} }
...@@ -292,30 +307,21 @@ export default function ForwardApprovals() { ...@@ -292,30 +307,21 @@ export default function ForwardApprovals() {
} }
async function handleApprove(record) { async function handleApprove(record) {
if ( if (canFirstApprove(record, currentUserId)) {
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
await handleUpdateRecord(record, { approval_status: 'approved' }); await handleUpdateRecord(record, { approval_status: 'approved' });
return; return;
} }
if ( if (canFinalApprove(record, currentUserId)) {
record.finalApprover === currentUserId &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending'
) {
await handleUpdateRecord(record, { final_approval_status: 'approved' }); await handleUpdateRecord(record, { final_approval_status: 'approved' });
return;
} }
setError('当前用户无权审批该环节,或审批状态已经变化。');
} }
async function handleReject(record) { async function handleReject(record) {
if ( if (canFirstApprove(record, currentUserId)) {
record.firstApprover === currentUserId &&
record.editorStatus === 'completed' &&
record.approvalStatus === 'pending'
) {
await handleUpdateRecord(record, { await handleUpdateRecord(record, {
editor_status: 'editing', editor_status: 'editing',
completed_by_id: '', completed_by_id: '',
...@@ -327,11 +333,7 @@ export default function ForwardApprovals() { ...@@ -327,11 +333,7 @@ export default function ForwardApprovals() {
return; return;
} }
if ( if (canFinalApprove(record, currentUserId)) {
record.finalApprover === currentUserId &&
record.approvalStatus === 'approved' &&
record.finalApprovalStatus === 'pending'
) {
await handleUpdateRecord(record, { await handleUpdateRecord(record, {
editor_status: 'editing', editor_status: 'editing',
completed_by_id: '', completed_by_id: '',
...@@ -339,7 +341,10 @@ export default function ForwardApprovals() { ...@@ -339,7 +341,10 @@ export default function ForwardApprovals() {
completed_at: '', completed_at: '',
final_approval_status: 'rejected', final_approval_status: 'rejected',
}); });
return;
} }
setError('当前用户无权审批该环节,或审批状态已经变化。');
} }
const canCurrentUserEdit = Boolean( const canCurrentUserEdit = Boolean(
...@@ -481,15 +486,7 @@ export default function ForwardApprovals() { ...@@ -481,15 +486,7 @@ export default function ForwardApprovals() {
<Button <Button
isDisabled={ isDisabled={
saving || saving ||
!( !canApproveCurrentStep(activeRecord, currentUserId)
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
} }
variant="danger-soft" variant="danger-soft"
onPress={() => handleReject(activeRecord)} onPress={() => handleReject(activeRecord)}
...@@ -499,15 +496,7 @@ export default function ForwardApprovals() { ...@@ -499,15 +496,7 @@ export default function ForwardApprovals() {
<Button <Button
isDisabled={ isDisabled={
saving || saving ||
!( !canApproveCurrentStep(activeRecord, currentUserId)
(activeRecord.firstApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'pending') ||
(activeRecord.finalApprover === currentUserId &&
activeRecord.editorStatus === 'completed' &&
activeRecord.approvalStatus === 'approved' &&
activeRecord.finalApprovalStatus === 'pending')
)
} }
variant="primary" variant="primary"
onPress={() => handleApprove(activeRecord)} onPress={() => handleApprove(activeRecord)}
......
This diff is collapsed.
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
## 3. 外部数据源契约 ## 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 ```ts
listSourceModels(): Promise<Array<{ listSourceModels(): Promise<Array<{
...@@ -56,11 +56,13 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -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` 并纯新增,不进行去重或更新。 `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. 核心流程 ## 4. 核心流程
...@@ -84,8 +86,8 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -84,8 +86,8 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- 来源声明数量与实际读取数量检查 - 来源声明数量与实际读取数量检查
- 字段别名、重复字段和类型检查 - 字段别名、重复字段和类型检查
- 所有数值、日期、布尔和对象字段的规范化检查 - 所有数值、日期、布尔和对象字段的规范化检查
8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成稳定动态数据 Subject 名称。 8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成以 `model_monitor_data_` 开头的稳定动态数据 Subject 名称。
9. 自动创建或扩展模型数据 Subject。只允许增加字段;类型冲突立即停止。 9. 自动创建或扩展普通模型数据 Subject。只允许增加字段,类型冲突立即停止。
10. 按 `MODEL_RUN_BATCH_ID` 查找批次;任何状态的本地批次一旦存在都拒绝重复同步,不再读取来源结果表。 10. 按 `MODEL_RUN_BATCH_ID` 查找批次;任何状态的本地批次一旦存在都拒绝重复同步,不再读取来源结果表。
11. 为来源行生成从 1 开始的 `source_row_number`。 11. 为来源行生成从 1 开始的 `source_row_number`。
12. 每 20 条分块纯新增写入,不查询已有结果行,也不执行更新;数据 Record name 由 `MODEL_RUN_BATCH_ID + source_row_number` 生成。 12. 每 20 条分块纯新增写入,不查询已有结果行,也不执行更新;数据 Record name 由 `MODEL_RUN_BATCH_ID + source_row_number` 生成。
...@@ -98,7 +100,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -98,7 +100,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- `partial`:部分行写入失败,成功数据保留。 - `partial`:部分行写入失败,成功数据保留。
- `failed`:校验、对象创建或同步过程失败。 - `failed`:校验、对象创建或同步过程失败。
- `partial/failed` 批次保留现场供排查,同样不允许重试,避免重复写入。 - `partial/failed` 批次保留现场供排查,同样不允许重试,避免重复写入。
- 批次保留错误摘要、新增数、失败数、来源行数和本地总数;更新数固定为 0。 - 批次保留错误摘要、新增数、失败数、来源行数和本地总数;更新数固定为 0。模型监控 Record 写入失败时保留已成功写入的数据和批次状态。
### 4.4 模型分组 ### 4.4 模型分组
...@@ -142,6 +144,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -142,6 +144,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。 6. 任一审批驳回后,填写状态恢复为 `editing`,清空上次完成人和完成时间,接收人可修改原回填内容。
7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。 7. 接收人重新完成填写时,`approval_status` 和 `final_approval_status` 均重置为 `pending`,流程从一级审批重新开始。
8. 终审通过后状态为已最终审批,流程结束。 8. 终审通过后状态为已最终审批,流程结束。
9. 接收人填写、完成填写、一级审批和终审在前端同时执行按钮禁用与提交事件校验;用户身份或流程状态不满足时不发送更新请求,并提示状态已经变化或当前用户无权操作。
## 5. 页面状态与异常处理 ## 5. 页面状态与异常处理
......
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
listSourceBatches, listSourceBatches,
listSourceModels, listSourceModels,
loadSourceBatchData, loadSourceBatchData,
} from './ModelMonitorMockService'; } from './ModelMonitorConnectorService';
export const MODEL_GROUP_SUBJECT = 'model_monitor_group'; export const MODEL_GROUP_SUBJECT = 'model_monitor_group';
export const MODEL_SUBJECT = 'model_monitor_model'; export const MODEL_SUBJECT = 'model_monitor_model';
...@@ -302,6 +302,7 @@ function normalizeSubject(subject = {}) { ...@@ -302,6 +302,7 @@ function normalizeSubject(subject = {}) {
id: candidate?._id || candidate?.id || candidate?.name || '', id: candidate?._id || candidate?.id || candidate?.name || '',
name: candidate?.name || candidate?.title || '', name: candidate?.name || candidate?.title || '',
title: candidate?.title || candidate?.name || '', title: candidate?.title || candidate?.name || '',
type: candidate?.type || 'normal',
fields: (Array.isArray(fields) ? fields : []).map(normalizeField), fields: (Array.isArray(fields) ? fields : []).map(normalizeField),
}; };
} }
...@@ -495,7 +496,7 @@ function validateSourceSelection(sourceModel = {}, sourceBatch = {}) { ...@@ -495,7 +496,7 @@ function validateSourceSelection(sourceModel = {}, sourceBatch = {}) {
throw new Error('来源批次的 MODEL_NAME 与所选模型名称不一致。'); throw new Error('来源批次的 MODEL_NAME 与所选模型名称不一致。');
} }
if (!/^[A-Z][A-Z0-9_$#]*(\.[A-Z][A-Z0-9_$#]*)?$/.test(sourceDataTableName)) { if (!/^[A-Z][A-Z0-9_$#]*(\.[A-Z][A-Z0-9_$#]*)?$/.test(sourceDataTableName)) {
throw new Error('来源批次的数据表名为空或不符合 Oracle 非引号标识符规范。'); throw new Error('来源批次的数据表名为空或不符合来源数据库标识符规范。');
} }
return { return {
...@@ -570,6 +571,11 @@ async function ensureSubject(definition) { ...@@ -570,6 +571,11 @@ async function ensureSubject(definition) {
return normalizeSubject(await createSubject(toSubjectPayload(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])); const existingFields = new Map(subject.fields.map((field) => [field.alias, field]));
for (const field of definition.fields) { for (const field of definition.fields) {
const existing = existingFields.get(field.name); const existing = existingFields.get(field.name);
......
...@@ -1339,6 +1339,19 @@ function connectorTest(data) { ...@@ -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) { function connectorList(data) {
return request(`/connector/list`, { return request(`/connector/list`, {
method: 'post', method: 'post',
...@@ -2080,6 +2093,7 @@ export { ...@@ -2080,6 +2093,7 @@ export {
connectorExecute, connectorExecute,
connectorInspect, connectorInspect,
connectorList, connectorList,
getSource,
connectorQuery, connectorQuery,
connectorQueryExecute, connectorQueryExecute,
connectorSelect, connectorSelect,
...@@ -2243,6 +2257,7 @@ export { ...@@ -2243,6 +2257,7 @@ export {
// Notice // Notice
queryNotice, queryNotice,
queryPlaywrightSession, queryPlaywrightSession,
querySources,
queryRecords, queryRecords,
queryRemovedRecord, queryRemovedRecord,
//rule //rule
......
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
} }
``` ```
开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。正式环境建议由部署流程预建固定 Subject。 开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。当前均使用后端默认的 `normal` 类型,不传 `type` 和 `external`;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。
### 2.3 增加 Subject 字段 ### 2.3 增加 Subject 字段
...@@ -158,7 +158,7 @@ ...@@ -158,7 +158,7 @@
## 5. 外部来源适配契约 ## 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 ```ts
listSourceModels(): Promise<Array<{ listSourceModels(): Promise<Array<{
...@@ -204,13 +204,14 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -204,13 +204,14 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- `batch_code` 直接保存 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,不再增加重复的来源跑批 ID 字段。 - `batch_code` 直接保存 `ANALYZE_MODEL_RUN_BATCH_INFO.MODEL_RUN_BATCH_ID`,不再增加重复的来源跑批 ID 字段。
- 批次的 `MODEL_NAME + MODEL_ID` 必须等于所选模型主键。 - 批次的 `MODEL_NAME + MODEL_ID` 必须等于所选模型主键。
- `MODEL_DATA_TABLE_NAME` 只能来自批次表,并必须匹配 Oracle 非引号标识符白名单;不得接受页面输入的任意表名。 - `MODEL_DATA_TABLE_NAME` 只能来自批次表,并必须匹配 Oracle 非引号标识符白名单;不得接受页面输入的任意表名。
- 动态表名不能作为绑定参数,后端必须先校验白名单再拼接;批次 ID 必须使用绑定参数查询。 - 动态表名不能作为绑定参数,必须先校验白名单再拼接;批次 ID 需要在适配层做字符串转义后再参与查询。
- 结果表必须按 `MODEL_RUN_BATCH_ID` 过滤,禁止只按表名读取整张模型结果表。 - 结果表必须按 `MODEL_RUN_BATCH_ID` 过滤,禁止只按表名读取整张模型结果表。
- `source_model_data_count` 存在时必须与实际读取行数一致,否则停止写入。 - `source_model_data_count` 存在时必须与实际读取行数一致,否则停止写入。
- 本地一旦存在相同 `batch_code` 的批次 Record,无论状态为何,界面和 service 层都必须拒绝再次同步。 - 本地一旦存在相同 `batch_code` 的批次 Record,无论状态为何,界面和 service 层都必须拒绝再次同步。
- `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。 - `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。
- 日期在写入 Record 前转换为 ISO 8601。 - 日期在写入 Record 前转换为 ISO 8601。
- 非法字段值或字段类型冲突必须在写入前报错。 - 非法字段值或字段类型冲突必须在写入前报错。
- 所有模型监控 Subject 当前均使用默认 `normal` 类型,数据写入平台内部存储;对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。
- 结果行只执行新增,不读取已有结果行,也不执行更新;`source_row_number` 保存本次来源读取顺序。 - 结果行只执行新增,不读取已有结果行,也不执行更新;`source_row_number` 保存本次来源读取顺序。
- 模型、批次和数据行 Record name 分别由 `MODEL_NAME + ID`、`MODEL_RUN_BATCH_ID`、`MODEL_RUN_BATCH_ID + source_row_number` 生成稳定哈希。 - 模型、批次和数据行 Record name 分别由 `MODEL_NAME + ID`、`MODEL_RUN_BATCH_ID`、`MODEL_RUN_BATCH_ID + source_row_number` 生成稳定哈希。
......
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
旧 `analysis_view_config`、`analysis_view_group`、`analysis_forward_record`、`analysis_forward_record_item`、`analysis_dashboard_board` 仅作为历史数据保留,新界面不得读取或写入。 旧 `analysis_view_config`、`analysis_view_group`、`analysis_forward_record`、`analysis_forward_record_item`、`analysis_dashboard_board` 仅作为历史数据保留,新界面不得读取或写入。
当前阶段所有模型监控 Subject 均使用默认 `normal` 类型,Record 保存在平台内部存储中;MySQL 仅作为模型来源数据源。对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。
## 1. 统一 Record 协议 ## 1. 统一 Record 协议
```ts ```ts
...@@ -147,7 +149,7 @@ type Record = { ...@@ -147,7 +149,7 @@ type Record = {
## 3. 动态模型数据 Subject ## 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 = { ...@@ -170,6 +172,6 @@ type Record = {
- 模型和批次不提供删除。 - 模型和批次不提供删除。
- 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。 - 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。
- `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。 - `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。
- 固定 Subject 在开发阶段由前端检查并创建;正式环境应由部署流程预建。 - 固定 Subject 在开发阶段由前端检查并创建为普通对象;正式环境应由部署流程预建。
- 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。 - 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。
- Subject/Record 写入失败必须明确报错,不得回退 localStorage。 - 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