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

@@ -41,12 +41,14 @@ import {
scheduleToFormValues,
type ScheduleFormValues,
} from './schedule-form';
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
import { classifySyncResult } from './sync-result';
// ---- Types ----
interface ClassScheduleItem {
id: number;
classId: number;
id: number | null;
classId: number | null;
classroomId: number;
weekDay: number;
startTime: string;
@@ -60,6 +62,7 @@ interface ClassScheduleItem {
notes: string | null;
createdAt: string;
updatedAt: string;
canViewDetails?: boolean;
}
interface ClassroomItem {
@@ -91,6 +94,9 @@ interface ScheduleSyncResult {
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
}
@@ -144,6 +150,9 @@ const SchedulesPage: React.FC = () => {
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
@@ -180,7 +189,14 @@ const SchedulesPage: React.FC = () => {
},
});
setSyncResult(res.data);
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
const classification = classifySyncResult(res.data);
if (classification.level === 'error') {
message.error(classification.message);
} else if (classification.level === 'warning') {
message.warning(classification.message);
} else {
message.success(classification.message);
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '同步失败');
@@ -238,7 +254,10 @@ const SchedulesPage: React.FC = () => {
setLoading(true);
try {
const [lookups, schedulesRes] = await Promise.all([
api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[] }>,
api.get('/class-schedules/lookups') as Promise<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
@@ -289,7 +308,7 @@ const SchedulesPage: React.FC = () => {
const classroomId = Number(cId);
filtered[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
const matched = schedules.filter((s) => s.classId === filterClassId);
const matched = filterSchedulesForClass(schedules, filterClassId);
if (matched.length > 0) {
filtered[classroomId][Number(wd)] = matched;
}
@@ -422,19 +441,22 @@ const SchedulesPage: React.FC = () => {
};
const openEditSchedule = (schedule: ClassScheduleItem) => {
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
if (schedule.scheduleType === 'RENTAL') {
message.warning('租赁排课请在租赁订单中修改');
return;
}
const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId };
setEditingSchedule(schedule);
setModalMode('edit');
form.setFieldsValue(scheduleToFormValues(schedule));
void loadClassTeachers(schedule.classId);
form.setFieldsValue(scheduleToFormValues(editableSchedule));
void loadClassTeachers(editableSchedule.classId);
};
// ---- Delete schedule ----
const handleDelete = async (id: number) => {
const handleDelete = async (id: number | null) => {
if (id === null) return;
try {
await api.delete(`/class-schedules/${id}`);
message.success('排课已删除');
@@ -681,20 +703,31 @@ const SchedulesPage: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={s.id}
title={`${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`}
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
title={
isMaskedSchedule(s)
? `已占用 · ${s.startTime}-${s.endTime}`
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
}
>
<div
style={{
background: '#e6f4ff',
border: '1px solid #91caff',
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
border: isMaskedSchedule(s)
? '1px solid #d9d9d9'
: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div style={{ fontWeight: 600, color: '#1677ff' }}>
<div
style={{
fontWeight: 600,
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
}}
>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
@@ -973,7 +1006,7 @@ const SchedulesPage: React.FC = () => {
) : (
selectedSchedules.map((s) => (
<Card
key={s.id}
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
@@ -987,14 +1020,16 @@ const SchedulesPage: React.FC = () => {
>
<div>
<div>
<strong></strong>
<Tag color="blue">{s.subject}</Tag>
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
</div>
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
{s.teacherId != null && (
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
)}
{!isMaskedSchedule(s) && s.teacherId != null && (
<div>
<strong></strong>
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
@@ -1010,46 +1045,50 @@ const SchedulesPage: React.FC = () => {
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{s.notes && (
{!isMaskedSchedule(s) && s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
</div>
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => openEditSchedule(s)}
>
</PermissionButton>
{!isMaskedSchedule(s) && (
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
)}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
</div>
{!isMaskedSchedule(s) && (
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => openEditSchedule(s)}
>
</PermissionButton>
)}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
</PermissionButton>
</Popconfirm>
</Space>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
)}
</div>
</Card>
))
@@ -1133,6 +1172,30 @@ const SchedulesPage: React.FC = () => {
showIcon
/>
)}
{syncResult.failedBatchCount > 0 && (
<>
<Alert
type="error"
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems}`}
description={
syncResult.errors.length > 0
? syncResult.errors.slice(0, 5).map((err, i) => (
<div key={i} style={{ wordBreak: 'break-all' }}>
{err}
</div>
))
: undefined
}
style={{ marginBottom: 16 }}
showIcon
/>
{syncResult.errors.length > 5 && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
... {syncResult.errors.length - 5}
</div>
)}
</>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>