Commit 2b4c88a1 authored by drigle's avatar drigle

fix: support external model monitor deployment

parent c0151ca8
...@@ -133,6 +133,6 @@ export default defineConfig({ ...@@ -133,6 +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', MODEL_MONITOR_CONNECTOR_NAME: 'MODEL_MONITOR_ORACLE',
}, },
}); });
{ {
"name": "model_analysis", "name": "model_analysis",
"version": "1.0.3", "version": "1.0.2",
"build": "abc", "build": "abc",
"description": "merge master", "description": "模型分析",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
"author": "yang", "author": "yang",
"spa": true "spa": true
......
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = async (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
const moduleUrl = (code) => `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
const appUrl = moduleUrl(await source('src/constants/app.js'));
globalThis.window = { location: { origin: 'https://example.com:8443' } };
const auth = await import(moduleUrl((await source('src/utils/authRedirect.js')).replace("'@/constants/app'", JSON.stringify(appUrl))));
const route = '/models/forwards?forwardId=a%2Fb#form';
for (const value of [route, `/model_analysis${route}`, `${window.location.origin}/model_analysis${route}`]) {
assert.equal(auth.resolveLoginRedirect(value), route);
const login = new URL(auth.getLoginUrl(value), window.location.origin);
assert.equal(login.pathname, '/model_analysis/login');
assert.equal(auth.resolveLoginRedirect(login.searchParams.get('redirect')), route);
}
for (const value of ['https://evil.example/a', '//evil.example/a', 'javascript:alert(1)', '/login?redirect=/login', '/model_analysis/login', 'https://example.com:8443/other-app']) {
assert.equal(auth.resolveLoginRedirect(value), '/');
}
const calls = [];
globalThis.__notificationCalls = calls;
const mocks = moduleUrl(`
export async function createNotice(data) { globalThis.__notificationCalls.push(data); return { name: 'notice' }; }
export async function sendNotice() {}
export async function loadUser(id) { return { _id: id, username: id }; }
export async function executeAction(name, data) { globalThis.__notificationCalls.push(data); return { taskId: 'task' }; }
`);
const notifications = await import(moduleUrl((await source('src/pages/project_manager_xy/forwardNotificationService.js'))
.replace("'@/services/DataService'", JSON.stringify(mocks))
.replace("'@/constants/app'", JSON.stringify(appUrl))));
for (const event of ['requested', 'first_approval_pending', 'final_approval_pending', 'first_rejected', 'final_rejected', 'final_approved']) {
calls.length = 0;
await notifications.dispatchForwardNotification(event, {
id: 'forward/123', recipientIds: ['recipient'], firstApprover: 'first', finalApprover: 'final',
initiatorId: 'sender', oaRecipientTaskIds: [], oaFinalApproverTaskId: [],
}, { _id: 'sender' });
const notice = calls.find((call) => call.settings);
assert.equal(notice.settings.url, '/model_analysis/models/forwards?forwardId=forward%2F123');
assert.ok(notice.content.includes(`${window.location.origin}${notice.settings.url}`));
for (const call of calls.filter((call) => call.returnUrl)) {
assert.equal(call.returnUrl, `${window.location.origin}${notice.settings.url}`);
}
}
console.log('Login round-trip and all six notification link scenarios passed.');
...@@ -2,6 +2,7 @@ import xinyuanLogo from '@/assets/xy-logo-red.png'; ...@@ -2,6 +2,7 @@ import xinyuanLogo from '@/assets/xy-logo-red.png';
import xinyuanLogoWhite from '@/assets/xy-logo-white.png'; import xinyuanLogoWhite from '@/assets/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app'; import { APP_DISPLAY_NAME } from '@/constants/app';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { resolveLoginRedirect } from '@/utils/authRedirect';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
...@@ -27,7 +28,7 @@ export default function LoginPage() { ...@@ -27,7 +28,7 @@ export default function LoginPage() {
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/'; const redirect = resolveLoginRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
...@@ -67,7 +68,7 @@ export default function LoginPage() { ...@@ -67,7 +68,7 @@ export default function LoginPage() {
authService.setToken(loginInfo.token); authService.setToken(loginInfo.token);
} }
history.push(redirect); history.replace(redirect);
} catch (requestError) { } catch (requestError) {
setError(requestError?.message || '登录失败,请重试'); setError(requestError?.message || '登录失败,请重试');
setCaptchaCode(''); setCaptchaCode('');
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
updateForwardRecordStatus, updateForwardRecordStatus,
} from './modelMonitorService'; } from './modelMonitorService';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'umi';
const FILTER_OPTIONS = [ const FILTER_OPTIONS = [
{ value: 'initiated', label: '我的发起' }, { value: 'initiated', label: '我的发起' },
...@@ -84,6 +85,8 @@ function getForwardEditorStatusTone(record) { ...@@ -84,6 +85,8 @@ function getForwardEditorStatusTone(record) {
export default function ForwardApprovals() { export default function ForwardApprovals() {
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const [searchParams] = useSearchParams();
const forwardId = searchParams.get('forwardId') || '';
const currentUserId = userInfo?._id || userInfo?.id || userInfo?.username || ''; const currentUserId = userInfo?._id || userInfo?.id || userInfo?.username || '';
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [activeFilter, setActiveFilter] = useState('initiated'); const [activeFilter, setActiveFilter] = useState('initiated');
...@@ -109,6 +112,8 @@ export default function ForwardApprovals() { ...@@ -109,6 +112,8 @@ export default function ForwardApprovals() {
(async () => { (async () => {
setLoading(true); setLoading(true);
setIsDetailDialogOpen(false);
setIsEditDialogOpen(false);
try { try {
await ensureModelMonitorSubjects(); await ensureModelMonitorSubjects();
const result = await loadForwardRecords(); const result = await loadForwardRecords();
...@@ -121,6 +126,25 @@ export default function ForwardApprovals() { ...@@ -121,6 +126,25 @@ export default function ForwardApprovals() {
setActiveFilter('involved'); setActiveFilter('involved');
} }
setError(''); setError('');
if (forwardId) {
const target = result.find((record) => record.id === forwardId || record.recordName === forwardId);
const involved = target && (
target.initiatorId === currentUserId || target.recipientIds.includes(currentUserId) ||
target.firstApprover === currentUserId || target.finalApprover === currentUserId
);
if (!involved) {
setError('链接对应的转发记录不存在,或当前账号无权查看。');
return;
}
setActiveFilter('involved');
setActiveRecordId(target.id);
setPage(0);
if (canRecipientEdit(target, currentUserId)) {
await openEditDialog(target);
} else {
setIsDetailDialogOpen(true);
}
}
} }
} catch (loadError) { } catch (loadError) {
if (!cancelled) { if (!cancelled) {
...@@ -136,7 +160,7 @@ export default function ForwardApprovals() { ...@@ -136,7 +160,7 @@ export default function ForwardApprovals() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUserId]); }, [currentUserId, forwardId]);
const editableInvolvedCount = useMemo( const editableInvolvedCount = useMemo(
() => () =>
......
...@@ -4,9 +4,7 @@ import { ...@@ -4,9 +4,7 @@ import {
getSource, getSource,
} from '@/services/DataService'; } from '@/services/DataService';
const DEFAULT_CONNECTOR_NAME = typeof MODEL_MONITOR_CONNECTOR_NAME !== 'undefined' && MODEL_MONITOR_CONNECTOR_NAME const DEFAULT_CONNECTOR_NAME = 'MODEL_MONITOR_ORACLE';
? MODEL_MONITOR_CONNECTOR_NAME
: 'MODEL_MONITOR_ORACLE';
const RESERVED_SOURCE_COLUMNS = new Set([ const RESERVED_SOURCE_COLUMNS = new Set([
'MODEL_ID', 'MODEL_ID',
......
...@@ -18,8 +18,14 @@ function uniqueIds(values = []) { ...@@ -18,8 +18,14 @@ function uniqueIds(values = []) {
return [...new Set(values.filter(Boolean).map(String))]; return [...new Set(values.filter(Boolean).map(String))];
} }
function forwardTargetUrl() { export function forwardTargetPath(record) {
return `${window.location.origin}${FORWARD_TARGET_PATH}`; const recordId = record?.id || record?.recordName;
if (!recordId) throw new Error('通知缺少转发记录 ID');
return `${FORWARD_TARGET_PATH}?forwardId=${encodeURIComponent(recordId)}`;
}
function forwardTargetUrl(record) {
return `${window.location.origin}${forwardTargetPath(record)}`;
} }
function unwrapResponse(response) { function unwrapResponse(response) {
...@@ -47,8 +53,8 @@ function notificationAudience(record, role) { ...@@ -47,8 +53,8 @@ function notificationAudience(record, role) {
return []; return [];
} }
function contentLink(prefix) { function contentLink(prefix, record) {
return `${prefix},请打开:${forwardTargetUrl()}`; return `${prefix},请打开:${forwardTargetUrl(record)}`;
} }
async function sendInternalNotice({ async function sendInternalNotice({
...@@ -56,18 +62,19 @@ async function sendInternalNotice({ ...@@ -56,18 +62,19 @@ async function sendInternalNotice({
content, content,
actor, actor,
audience, audience,
record,
}) { }) {
const to = uniqueIds(audience); const to = uniqueIds(audience);
if (!to.length) return null; if (!to.length) return null;
const notice = unwrapResponse(await createNotice({ const notice = unwrapResponse(await createNotice({
title, title,
content: contentLink(content), content: contentLink(content, record),
type: 'internal', type: 'internal',
category: 'message', category: 'message',
from: actor.id, from: actor.id,
to, to,
settings: { url: FORWARD_TARGET_PATH }, settings: { url: forwardTargetPath(record) },
})); }));
if (!notice?.name) throw new Error('站内通知创建结果缺少 name'); if (!notice?.name) throw new Error('站内通知创建结果缺少 name');
...@@ -85,6 +92,7 @@ async function createOaProgress({ ...@@ -85,6 +92,7 @@ async function createOaProgress({
title, title,
actor, actor,
userId, userId,
record,
}) { }) {
const receiver = await loadOaUser(userId); const receiver = await loadOaUser(userId);
const result = await executeAction('oa_notice:createProgress', { const result = await executeAction('oa_notice:createProgress', {
...@@ -97,7 +105,7 @@ async function createOaProgress({ ...@@ -97,7 +105,7 @@ async function createOaProgress({
sendUserId: actor.id, sendUserId: actor.id,
sendUserName: actor.username || '', sendUserName: actor.username || '',
sendDisplayName: actor.name || '', sendDisplayName: actor.name || '',
returnUrl: forwardTargetUrl(), returnUrl: forwardTargetUrl(record),
}); });
const taskId = unwrapResponse(result)?.taskId; const taskId = unwrapResponse(result)?.taskId;
if (!taskId) throw new Error(`OA 待办创建结果缺少 taskId:${userId}`); if (!taskId) throw new Error(`OA 待办创建结果缺少 taskId:${userId}`);
...@@ -137,7 +145,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -137,7 +145,7 @@ export async function dispatchForwardNotification(event, record, user) {
name: user?.display_name || user?.displayName || user?.username || '', name: user?.display_name || user?.displayName || user?.username || '',
}; };
const modelLabel = `${record.modelName || '模型'} / ${record.batchName || '批次'}`; const modelLabel = `${record.modelName || '模型'} / ${record.batchName || '批次'}`;
const context = { actor }; const context = { actor, record };
const errors = []; const errors = [];
const taskUpdates = {}; const taskUpdates = {};
...@@ -151,6 +159,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -151,6 +159,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据填写', title: '模型监控数据填写',
content: `${user?.display_name || user?.username || '发起人'} 已向您分发 ${modelLabel} 的整改数据`, content: `${user?.display_name || user?.username || '发起人'} 已向您分发 ${modelLabel} 的整改数据`,
actor, actor,
record,
audience: record.recipientIds, audience: record.recipientIds,
}), }),
]); ]);
...@@ -174,6 +183,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -174,6 +183,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据待一级审批', title: '模型监控数据待一级审批',
content: `${modelLabel} 已完成填写,等待您一级审批`, content: `${modelLabel} 已完成填写,等待您一级审批`,
actor, actor,
record,
audience: [record.firstApprover], audience: [record.firstApprover],
}), }),
]); ]);
...@@ -196,6 +206,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -196,6 +206,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据待终审', title: '模型监控数据待终审',
content: `${modelLabel} 已通过一级审批,等待您终审`, content: `${modelLabel} 已通过一级审批,等待您终审`,
actor, actor,
record,
audience: [record.finalApprover], audience: [record.finalApprover],
}), }),
]); ]);
...@@ -221,6 +232,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -221,6 +232,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据已驳回', title: '模型监控数据已驳回',
content: `${modelLabel} 一级审批已驳回,请重新填写`, content: `${modelLabel} 一级审批已驳回,请重新填写`,
actor, actor,
record,
audience: uniqueIds([...record.recipientIds, record.initiatorId]), audience: uniqueIds([...record.recipientIds, record.initiatorId]),
}), }),
]); ]);
...@@ -246,6 +258,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -246,6 +258,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据终审驳回', title: '模型监控数据终审驳回',
content: `${modelLabel} 终审已驳回,请重新填写`, content: `${modelLabel} 终审已驳回,请重新填写`,
actor, actor,
record,
audience: uniqueIds([...record.recipientIds, record.initiatorId]), audience: uniqueIds([...record.recipientIds, record.initiatorId]),
}), }),
]); ]);
...@@ -263,6 +276,7 @@ export async function dispatchForwardNotification(event, record, user) { ...@@ -263,6 +276,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控流程已结束', title: '模型监控流程已结束',
content: `${modelLabel} 已通过终审`, content: `${modelLabel} 已通过终审`,
actor, actor,
record,
audience: notificationAudience(record, 'participants'), audience: notificationAudience(record, 'participants'),
}), }),
]); ]);
......
...@@ -20,7 +20,16 @@ ...@@ -20,7 +20,16 @@
旧 `/analysis/overview`、`/analysis/views`、`/analysis/forwards`、`/analysis/boards` 仅用于重定向,不再加载旧分析页面。 旧 `/analysis/overview`、`/analysis/views`、`/analysis/forwards`、`/analysis/boards` 仅用于重定向,不再加载旧分析页面。
### 2.1 应用切换 ### 2.1 通知链接与登录回跳
- 新站内通知正文、`settings.url` 和 OA 待办 `returnUrl` 均定位 `/model_analysis/models/forwards?forwardId=<转发记录ID>`;绝对地址使用发送时浏览器的 `window.location.origin`(协议、域名及端口)。发送环境必须是接收人可访问的部署地址。
- 未登录打开链接时进入 `/model_analysis/login?redirect=...`;登录过期收到 401 时同样保留路径、查询参数和 hash。登录失败留在登录页并保留目标,成功以 replace 回到原页面。
- 回跳参数统一转换成不含应用 base 的 Umi 路由,兼容同源完整应用 URL;拒绝跨域、脚本地址及登录页自身,非法参数回到首页。403 不再由请求拦截器清除登录态。
- 进入转发页后查询最新转发记录,使用 `forwardId` 匹配 ID 或 Record name,并检查当前用户是否为发起人、接收人或审批人。自动切换“我参与的”;可填写接收人打开填写表单,其他参与者打开审批详情,操作仍受现有身份和状态校验控制。
- 记录不存在或账号非参与者时显示明确错误,不自动打开其他记录;加载失败显示错误。刷新带参数的链接会重新定位,关闭弹窗后本次页面不重复自动打开。
- 历史通知不含 `forwardId` 时继续进入列表,不推断对应记录;仅打开链接不会提交或改变业务状态。
### 2.2 应用切换
- 侧栏应用切换入口打开 HeroUI 应用切换弹窗,不改变当前模型监控路由和登录状态。 - 侧栏应用切换入口打开 HeroUI 应用切换弹窗,不改变当前模型监控路由和登录状态。
- 可访问应用继续由 `GET /application/info` 与当前登录用户的 `applications` 合并得到,保留原排序、回退和跳转规则。 - 可访问应用继续由 `GET /application/info` 与当前登录用户的 `applications` 合并得到,保留原排序、回退和跳转规则。
...@@ -30,7 +39,7 @@ ...@@ -30,7 +39,7 @@
## 3. 外部数据源契约 ## 3. 外部数据源契约
生产环境的外部来源通过通用 Connector 接口读取固定 Oracle 数据源,开发联调允许使用遵循同一表结构的 MySQL 5.7+ 测试源;适配器根据 Connector 的 `settings.client` 使用 Oracle 双引号或 MySQL 反引号引用动态表与字段。同步后的模型监控数据写入平台普通 Subject。模型监控页面不提供数据源选择器,来源 connector 名称由构建常量 `MODEL_MONITOR_CONNECTOR_NAME` 提供。UI 仅依赖以下方法,页面调用保持不变: 生产环境的外部来源通过通用 Connector 接口读取固定 Oracle 数据源,开发联调允许使用遵循同一表结构的 MySQL 5.7+ 测试源;适配器根据 Connector 的 `settings.client` 使用 Oracle 双引号或 MySQL 反引号引用动态表与字段。同步后的模型监控数据写入已有 Subject;对象缺失时创建绑定 `dm8` 的外部 Subject。模型监控页面不提供数据源选择器,来源 connector 别名固定为 `MODEL_MONITOR_ORACLE`。UI 仅依赖以下方法,页面调用保持不变:
```ts ```ts
listSourceModels(): Promise<Array<{ listSourceModels(): Promise<Array<{
...@@ -60,9 +69,9 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -60,9 +69,9 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
`ANALYZE_MODEL_DATA_MX_*` 结果行不要求业务主键。每个批次只允许同步一次,结果行按来源读取顺序记录 `source_row_number` 并纯新增,不进行去重或更新。 `ANALYZE_MODEL_DATA_MX_*` 结果行不要求业务主键。每个批次只允许同步一次,结果行按来源读取顺序记录 `source_row_number` 并纯新增,不进行去重或更新。
MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` 初始化。脚本只创建不存在的来源表,并按稳定键更新 4 个模型、16 个批次和 80 条结果样例;每个模型包含 2025 年历史批次和 2026 年当前批次,便于验证按年份分组、年份切换、跨年份勾选和全选口径。使用测试源启动前端时,必须把 `MODEL_MONITOR_CONNECTOR_NAME` 配置为对应的 MySQL Connector;当前测试环境的 Connector 名称为 `oqJDeVZszjRM`,可用 `MODEL_MONITOR_CONNECTOR_NAME=oqJDeVZszjRM npm run dev` 启动。该环境只验证通用同步流程,不替代 Oracle 驱动和方言的集成验收。 MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` 初始化。脚本只创建不存在的来源表,并按稳定键更新 4 个模型、16 个批次和 80 条结果样例;每个模型包含 2025 年历史批次和 2026 年当前批次,便于验证按年份分组、年份切换、跨年份勾选和全选口径。历史测试源别名 `oqJDeVZszjRM` 不再使用;运行时来源统一为 `MODEL_MONITOR_ORACLE`,Oracle 驱动和 DM8 外部存储仍需集成验收。
未来若固定数据源配置变化,只替换构建常量和适配器实现,不修改页面和本地持久化服务。 数据源别名统一固定:目标为 `dm8`,Oracle 来源为 `MODEL_MONITOR_ORACLE`;不再用环境变量覆盖来源别名。
## 4. 核心流程 ## 4. 核心流程
...@@ -70,7 +79,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -70,7 +79,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
1. 用户进入任一模型监控页面。 1. 用户进入任一模型监控页面。
2. 开发阶段前端依次检查 5 个固定 `model_monitor_*` Subject。 2. 开发阶段前端依次检查 5 个固定 `model_monitor_*` Subject。
3. 缺失 Subject 时调用 Subject 创建接口。 3. 仅在 Subject 查询返回 404 时创建外部对象(`type: external`、`external.connector: dm8`);认证、网络及服务端异常直接报错。已有同名对象直接复用,不限制其为内部或外部对象,也不自动转换存储类型。
4. 初始化失败时显示明确错误,页面不得使用 localStorage 或演示 Record 冒充真实数据。 4. 初始化失败时显示明确错误,页面不得使用 localStorage 或演示 Record 冒充真实数据。
### 4.2 批量同步模型批次 ### 4.2 批量同步模型批次
...@@ -87,7 +96,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -87,7 +96,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
- 字段别名、重复字段和类型检查 - 字段别名、重复字段和类型检查
- 所有数值、日期、布尔和对象字段的规范化检查 - 所有数值、日期、布尔和对象字段的规范化检查
8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成以 `model_monitor_data_` 开头的稳定动态数据 Subject 名称。 8. 本地没有模型时按 `MODEL_NAME + ID` 创建稳定 `model_monitor_model` Record,并生成以 `model_monitor_data_` 开头的稳定动态数据 Subject 名称。
9. 自动创建或扩展普通模型数据 Subject。只允许增加字段,类型冲突立即停止。 9. 在创建模型、批次 Record 前,先复用或扩展已有模型数据 Subject;缺失时创建绑定 `dm8` 的外部 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` 生成。
...@@ -186,3 +195,10 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -186,3 +195,10 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
- 批量同步模型批次弹窗使用紧凑步骤条、年份筛选与全选工具行;年份下按来源模型分段展示批次,同一模型的批次集中在同一列表区,并在分段标题展示模型名称、编码和批次数量。各分段使用 HeroUI Pro ListView 原生多选,切换年份继续保留其它年份已选项,已同步批次继续禁用,选择和同步口径不变。 - 批量同步模型批次弹窗使用紧凑步骤条、年份筛选与全选工具行;年份下按来源模型分段展示批次,同一模型的批次集中在同一列表区,并在分段标题展示模型名称、编码和批次数量。各分段使用 HeroUI Pro ListView 原生多选,切换年份继续保留其它年份已选项,已同步批次继续禁用,选择和同步口径不变。
- 查看态详情统一放在单一 secondary Surface;编辑态字段直接位于 Modal Overlay,字段必填、枚举来源、主键禁用和完成填写校验保持原规则。 - 查看态详情统一放在单一 secondary Surface;编辑态字段直接位于 Modal Overlay,字段必填、枚举来源、主键禁用和完成填写校验保持原规则。
- Loading、错误和空状态由 HeroUI/HeroUI Pro 语义组件展示;不得生成测试记录或用伪造数值填充空页面。 - Loading、错误和空状态由 HeroUI/HeroUI Pro 语义组件展示;不得生成测试记录或用伪造数值填充空页面。
### 同步审查与后续优化(2026-09-20)
- 当前流程:检查固定对象 → 加载 Oracle 模型/批次 → 拒绝已有批次 → 读取并校验来源数据 → 预检动态数据对象 → 创建模型/批次 → 每 20 行并发新增 → 统计并更新状态。
- 存量内部或外部对象均可直接复用;同步流程只在对象缺失时按 `external.connector = dm8` 创建新对象,不自动转换已有对象的存储类型。
- 后续建议:把同步迁至服务端任务,增加批次唯一约束/原子抢占、分页读取及批量写入,避免关闭页面中断和多用户竞态。
- 已知限制:写入后统计或状态保存失败可能使批次数量失真;需要持久化进度及独立对账恢复。当前一次性同步约束保留,失败批次不可直接重试。
...@@ -278,6 +278,8 @@ function toFieldPayload(field) { ...@@ -278,6 +278,8 @@ function toFieldPayload(field) {
function toSubjectPayload(definition) { function toSubjectPayload(definition) {
return { return {
type: 'external',
external: { connector: 'dm8' },
name: definition.name, name: definition.name,
title: definition.title, title: definition.title,
description: definition.description || `${definition.title}自动配置`, description: definition.description || `${definition.title}自动配置`,
...@@ -310,6 +312,7 @@ function normalizeSubject(subject = {}) { ...@@ -310,6 +312,7 @@ function normalizeSubject(subject = {}) {
name: candidate?.name || candidate?.title || '', name: candidate?.name || candidate?.title || '',
title: candidate?.title || candidate?.name || '', title: candidate?.title || candidate?.name || '',
type: candidate?.type || 'normal', type: candidate?.type || 'normal',
external: candidate?.external,
fields: (Array.isArray(fields) ? fields : []).map(normalizeField), fields: (Array.isArray(fields) ? fields : []).map(normalizeField),
}; };
} }
...@@ -579,13 +582,10 @@ async function ensureSubject(definition) { ...@@ -579,13 +582,10 @@ async function ensureSubject(definition) {
let subject; let subject;
try { try {
subject = normalizeSubject(await loadSubject(definition.name)); subject = normalizeSubject(await loadSubject(definition.name));
} catch (_error) { } catch (error) {
return normalizeSubject(await createSubject(toSubjectPayload(definition))); const status = error?.response?.status ?? error?.status ?? error?.data?.code ?? error?.code;
} if (Number(status) !== 404) throw error;
subject = 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]));
...@@ -874,6 +874,7 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr ...@@ -874,6 +874,7 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
onProgress({ stage: 'validating', completed: 0, total: normalizedRows.length }); onProgress({ stage: 'validating', completed: 0, total: normalizedRows.length });
const subjectName = model?.dataSubjectName || dataSubjectName(sourceModelKey); const subjectName = model?.dataSubjectName || dataSubjectName(sourceModelKey);
await ensureDataSubject(subjectName, normalizedSourceModel.model_name, columns);
try { try {
if (model) { if (model) {
model = { model = {
...@@ -927,8 +928,6 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr ...@@ -927,8 +928,6 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
}), createRecordName('model_batch', sourceBatchCode)); }), createRecordName('model_batch', sourceBatchCode));
await saveModelSyncState(model, 'syncing', ''); await saveModelSyncState(model, 'syncing', '');
await ensureDataSubject(subjectName, model.modelName, columns);
let insertedCount = 0; let insertedCount = 0;
let failedCount = 0; let failedCount = 0;
const errors = []; const errors = [];
......
...@@ -29,6 +29,8 @@ ...@@ -29,6 +29,8 @@
- 其他应用路径和外部 HTTP(S) 地址在新窗口打开,并使用 `noopener,noreferrer`。 - 其他应用路径和外部 HTTP(S) 地址在新窗口打开,并使用 `noopener,noreferrer`。
- 打开关联流程前先按现有协议标记当前通知已读;不得执行通知 HTML 或脚本。 - 打开关联流程前先按现有协议标记当前通知已读;不得执行通知 HTML 或脚本。
- 模型转发新通知携带 `forwardId`,登录回跳保留该参数,进入后按身份与最新状态自动打开填写表单或审批详情;历史无参数通知保持列表入口。详见 `../model-monitor.business-flow.md` 的“通知链接与登录回跳”。
## 异常与限制 ## 异常与限制
- 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。 - 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。
......
...@@ -25,6 +25,8 @@ ...@@ -25,6 +25,8 @@
```json ```json
{ {
"type": "external",
"external": { "connector": "dm8" },
"name": "model_monitor_model", "name": "model_monitor_model",
"title": "模型主档", "title": "模型主档",
"description": "模型主档自动配置", "description": "模型主档自动配置",
...@@ -44,7 +46,7 @@ ...@@ -44,7 +46,7 @@
} }
``` ```
开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。当前均使用后端默认的 `normal` 类型,不传 `type` 和 `external`;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。 开发阶段用于自动创建固定 `model_monitor_*` Subject 和每模型动态数据 Subject。统一传 `type: "external"` 和 `external: { "connector": "dm8" }`;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。
### 2.3 增加 Subject 字段 ### 2.3 增加 Subject 字段
...@@ -158,7 +160,7 @@ ...@@ -158,7 +160,7 @@
## 5. 外部来源适配契约 ## 5. 外部来源适配契约
当前仓库通过通用 Connector 接口读取生产 Oracle 数据源;开发联调允许接入遵循相同来源表结构的 MySQL 5.7+ 测试数据源。同步后的模型、批次和动态数据写入平台普通 Subject。模型监控页面不直接拼 SQL,也不让用户在页面上选择数据源;来源 connector 名称由构建常量 `MODEL_MONITOR_CONNECTOR_NAME` 提供。前端通过 `/connector/manage/:name` 读取来源连接配置,再用 `/connector/execute` 与 `/connector/inspect` 读取模型、批次和批次数据;动态标识符必须根据 Connector 客户端使用对应方言引用。接口适配层必须保持以下签名: 当前仓库通过通用 Connector 接口读取生产 Oracle 数据源;开发联调允许接入遵循相同来源表结构的 MySQL 5.7+ 测试数据源。同步后的模型、批次和动态数据写入已有 Subject;对象缺失时创建绑定 `dm8` 的外部 Subject。模型监控页面不直接拼 SQL,也不让用户在页面上选择数据源;来源 connector 别名固定为 `MODEL_MONITOR_ORACLE`。前端通过 `/connector/manage/:name` 读取来源连接配置,再用 `/connector/execute` 与 `/connector/inspect` 读取模型、批次和批次数据;动态标识符必须根据 Connector 客户端使用对应方言引用。接口适配层必须保持以下签名:
```ts ```ts
listSourceModels(): Promise<Array<{ listSourceModels(): Promise<Array<{
...@@ -211,7 +213,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise ...@@ -211,7 +213,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
- `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。 - `columns[].name` 必须匹配 `^[a-z][a-z0-9_]*$` 且不可重复。
- 日期在写入 Record 前转换为 ISO 8601。 - 日期在写入 Record 前转换为 ISO 8601。
- 非法字段值或字段类型冲突必须在写入前报错。 - 非法字段值或字段类型冲突必须在写入前报错。
- 所有模型监控 Subject 当前均使用默认 `normal` 类型,数据写入平台内部存储;对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。 - 已有模型监控 Subject 不限制为 `normal` 或 `external`;仅在同步时发现对象缺失,才创建绑定 `external.connector = dm8` 的外部对象。对象名统一使用 `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` 生成稳定哈希。
...@@ -297,13 +299,13 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及 ...@@ -297,13 +299,13 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
```json ```json
{ {
"title": "模型监控数据填写", "title": "模型监控数据填写",
"content": "接收人已收到转发,请打开:https://example.com/model_analysis/models/forwards", "content": "接收人已收到转发,请打开:https://example.com/model_analysis/models/forwards?forwardId=<转发记录ID>",
"type": "internal", "type": "internal",
"category": "message", "category": "message",
"from": "发起人 _id", "from": "发起人 _id",
"to": ["接收人 _id"], "to": ["接收人 _id"],
"settings": { "settings": {
"url": "/model_analysis/models/forwards" "url": "/model_analysis/models/forwards?forwardId=<转发记录ID>"
} }
} }
``` ```
...@@ -329,7 +331,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及 ...@@ -329,7 +331,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
"sendUserId": "发起人 _id", "sendUserId": "发起人 _id",
"sendUserName": "发起人账号", "sendUserName": "发起人账号",
"sendDisplayName": "发起人显示名", "sendDisplayName": "发起人显示名",
"returnUrl": "https://example.com/model_analysis/models/forwards" "returnUrl": "https://example.com/model_analysis/models/forwards?forwardId=<转发记录ID>"
} }
``` ```
...@@ -344,3 +346,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及 ...@@ -344,3 +346,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
"taskId": "待关闭的 OA 任务 ID" "taskId": "待关闭的 OA 任务 ID"
} }
``` ```
## 外部对象初始化限制
外部创建协议已核对后端 `SubjectManager.createSubject`:后端生成物理表名 `dm_<Subject name>`,业务 Subject name 保持不变。前端仅对 GET Subject 的 404 创建绑定 `dm8` 的外部对象,不把认证或网络错误当作对象缺失;已有内部或外部对象均直接复用,不检查或转换其存储类型。新增字段仍调用 `POST /subject/:name/field`,由后端按对象实际类型处理字段结构变更。
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
旧 `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_` 前缀,不附加数据库类型前缀。 已有模型监控 Subject 可以是 `normal` 或 `external`,同步流程不限制或转换存量对象类型;对象缺失时新建绑定 `dm8` 的外部对象。Oracle 来源别名固定为 `MODEL_MONITOR_ORACLE`。对象名统一使用 `model_monitor_` 前缀,不附加数据库类型前缀。
## 1. 统一 Record 协议 ## 1. 统一 Record 协议
...@@ -38,7 +38,7 @@ type Record = { ...@@ -38,7 +38,7 @@ type Record = {
## 动态模型数据 Subject ## 动态模型数据 Subject
每个模型首次同步时按 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID` 自动创建稳定的 `model_monitor_data_<stable_id>` 普通动态数据 Subject,后续批次继续使用同一 Subject。 每个模型首次同步时按 `ANALYZE_MXVIEW_INFO.MODEL_NAME + ID` 自动创建稳定的 `model_monitor_data_<stable_id>` 外部动态数据 Subject,后续批次继续使用同一 Subject。
固定系统字段: 固定系统字段:
...@@ -61,7 +61,7 @@ type Record = { ...@@ -61,7 +61,7 @@ type Record = {
- 模型和批次不提供删除。 - 模型和批次不提供删除。
- 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。 - 模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。
- `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。 - `hidden_record_ids` 为批次级共享状态,与转发的 `excluded_record_ids` 分开维护。
- 固定 Subject 在开发阶段由前端检查并创建为普通对象;正式环境应由部署流程预建。 - 固定 Subject 在开发阶段由前端检查;已有内部或外部对象均直接复用,缺失时创建绑定 `dm8` 的外部对象。正式环境建议由部署流程预建。
- 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。 - 已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。
- Subject/Record 写入失败必须明确报错,不得回退 localStorage。 - Subject/Record 写入失败必须明确报错,不得回退 localStorage。
...@@ -74,7 +74,7 @@ type Record = { ...@@ -74,7 +74,7 @@ type Record = {
### `model_monitor_group`:模型分组 ### `model_monitor_group`:模型分组
- 类型:`normal` - 类型:不限制(缺失时创建 `external.connector = dm8`)
- 字段数量:4 - 字段数量:4
- 说明:模型分组自动配置 - 说明:模型分组自动配置
...@@ -87,7 +87,7 @@ type Record = { ...@@ -87,7 +87,7 @@ type Record = {
### `model_monitor_model`:模型主档 ### `model_monitor_model`:模型主档
- 类型:`normal` - 类型:不限制(缺失时创建 `external.connector = dm8`)
- 字段数量:19 - 字段数量:19
- 说明:模型主档自动配置 - 说明:模型主档自动配置
...@@ -115,7 +115,7 @@ type Record = { ...@@ -115,7 +115,7 @@ type Record = {
### `model_monitor_batch`:模型批次 ### `model_monitor_batch`:模型批次
- 类型:`normal` - 类型:不限制(缺失时创建 `external.connector = dm8`)
- 字段数量:25 - 字段数量:25
- 说明:模型批次自动配置 - 说明:模型批次自动配置
...@@ -149,7 +149,7 @@ type Record = { ...@@ -149,7 +149,7 @@ type Record = {
### `model_monitor_forward_record`:模型转发审批记录 ### `model_monitor_forward_record`:模型转发审批记录
- 类型:`normal` - 类型:不限制(缺失时创建 `external.connector = dm8`)
- 字段数量:26 - 字段数量:26
- 说明:模型转发审批记录自动配置 - 说明:模型转发审批记录自动配置
...@@ -184,7 +184,7 @@ type Record = { ...@@ -184,7 +184,7 @@ type Record = {
### `model_monitor_forward_item`:模型转发回填明细 ### `model_monitor_forward_item`:模型转发回填明细
- 类型:`normal` - 类型:不限制(缺失时创建 `external.connector = dm8`)
- 字段数量:16 - 字段数量:16
- 说明:模型转发回填明细自动配置 - 说明:模型转发回填明细自动配置
......
import { APP_BASE_PATH, getAppUrl, stripAppBase } from '@/constants/app';
// Umi navigation uses routes without the deployment base; browser navigation includes it.
export function resolveLoginRedirect(value, origin = window.location.origin) {
if (!value || typeof value !== 'string') return '/';
try {
if (!value.startsWith('/') && !/^https?:\/\//i.test(value)) return '/';
const url = new URL(value, origin);
if (url.origin !== origin) return '/';
if (/^https?:/i.test(value) && url.pathname !== APP_BASE_PATH &&
!url.pathname.startsWith(`${APP_BASE_PATH}/`)) return '/';
const pathname = stripAppBase(url.pathname);
if (pathname === '/login' || pathname.startsWith('/login/')) return '/';
return `${pathname}${url.search}${url.hash}`;
} catch {
return '/';
}
}
export function getLoginRoute(target) {
return `/login?redirect=${encodeURIComponent(resolveLoginRedirect(target))}`;
}
export function getLoginUrl(target) {
return getAppUrl(getLoginRoute(target));
}
import { extend } from 'umi-request'; import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress'; import progressMiddleware from 'umi-request-progress';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { stripAppBase } from '@/constants/app';
import { getLoginUrl } from '@/utils/authRedirect';
// 使用 .umirc.ts 中定义的全局变量 // 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL; const baseUrl = STREAMS_API_URL;
...@@ -39,10 +41,11 @@ function createRequestWithPrefix(prefix = baseUrl) { ...@@ -39,10 +41,11 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)'); console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整) // 登录失败留在当前登录页,保留原始回跳参数并显示接口错误。
location.href = '/login?redirect=' + encodeURIComponent(location.href); if (stripAppBase(window.location.pathname) !== '/login') {
window.location.replace(getLoginUrl(window.location.href));
}
} else if (response?.status === 403) { } else if (response?.status === 403) {
authService.clearUser();
console.warn('[request] forbidden (403)'); console.warn('[request] forbidden (403)');
} else if (response?.status >= 400) { } else if (response?.status >= 400) {
try { try {
......
import authService from '@/services/authService'; import authService from '@/services/authService';
import { getLoginRoute } from '@/utils/authRedirect';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Navigate, Outlet, useLocation } from 'umi'; import { history, Navigate, Outlet, useLocation } from 'umi';
...@@ -93,9 +94,7 @@ export default function AuthWrapper() { ...@@ -93,9 +94,7 @@ export default function AuthWrapper() {
if (!userInfo || !authService.getToken()) { if (!userInfo || !authService.getToken()) {
return ( return (
<Navigate <Navigate
to={`/login?redirect=${encodeURIComponent( to={getLoginRoute(location.pathname + location.search + location.hash)}
location.pathname + location.search,
)}`}
replace replace
/> />
); );
......
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