fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling

- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers
- H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables
- M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps)
- M2: split handleSave try/catch — save errors vs reload errors shown distinctly
- M3: added provider field validation before AI config test request
- Added SSE scoping regression tests (import service + controller)
- Added FK check failure rollback test (database-migrations.spec)
- Updated controller spec expectations for userId parameter

Co-authored-by: Code Review <branch-review>
This commit is contained in:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -7,6 +7,7 @@ import {
import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, BankOutlined, UserOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
@@ -64,6 +65,28 @@ interface ImportResult {
skipped: number;
}
interface DingTalkAttendanceGroup {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
interface AttendanceGroupResponse {
success: boolean;
data: DingTalkAttendanceGroup[];
}
interface DeleteAttendanceGroupsResponse {
success: boolean;
data: {
total: number;
deleted: Array<{ groupId: number; groupName: string }>;
failed: Array<{ groupId: number; groupName: string; error: string }>;
};
}
const IntegrationConfigPage: React.FC = () => {
const { hasAllPermissions } = usePermission();
const [loading, setLoading] = useState(false);
@@ -85,6 +108,10 @@ const IntegrationConfigPage: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [deletingGroups, setDeletingGroups] = useState(false);
const fetchConfig = async () => {
@@ -194,6 +221,7 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => {
const users = node.users ?? [];
@@ -282,6 +310,41 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const openDeleteAllGroups = async () => {
setLoadingGroups(true);
try {
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
setAttendanceGroups(response.data);
setDeleteGroupsOpen(true);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
} finally {
setLoadingGroups(false);
}
};
const deleteAllGroups = async () => {
setDeletingGroups(true);
try {
const response = await api.post<DeleteAttendanceGroupsResponse>(
'/sync/dingtalk/attendance-groups/delete-all',
);
setDeleteGroupsOpen(false);
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`已删除 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`已删除钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '删除钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
};
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? [
{
@@ -314,6 +377,15 @@ const IntegrationConfigPage: React.FC = () => {
>
</Button>
<PermissionButton
permission="sync:trigger"
danger
icon={<DeleteOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
{drawerOpen && (
@@ -414,6 +486,38 @@ const IntegrationConfigPage: React.FC = () => {
</Modal>
</Drawer>
)}
<Modal
title="确认删除钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部删除"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
onOk={deleteAllGroups}
onCancel={() => setDeleteGroupsOpen(false)}
>
<Alert
type="error"
showIcon
message={`将永久删除钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会删除。删除后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List
size="small"
bordered
dataSource={attendanceGroups}
style={{ maxHeight: 280, overflow: 'auto' }}
renderItem={(group) => (
<List.Item>
<List.Item.Meta
title={group.group_name}
description={`ID ${group.group_id} · ${group.member_count}`}
/>
</List.Item>
)}
/>
</Modal>
</div>
),
},