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:
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ScheduleFormValues {
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
id?: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterSchedulesForClass,
|
||||
isMaskedSchedule,
|
||||
type VisibleSchedule,
|
||||
} from './schedule-visibility';
|
||||
|
||||
const ownSchedule: VisibleSchedule = {
|
||||
id: 1,
|
||||
classId: 8,
|
||||
canViewDetails: true,
|
||||
};
|
||||
const otherClassBusyBlock: VisibleSchedule = {
|
||||
id: null,
|
||||
classId: null,
|
||||
canViewDetails: false,
|
||||
};
|
||||
const anotherOwnVisibleSchedule: VisibleSchedule = {
|
||||
id: 2,
|
||||
classId: 9,
|
||||
canViewDetails: true,
|
||||
};
|
||||
|
||||
describe('schedule visibility helpers', () => {
|
||||
it('keeps masked shared-room occupancy visible while filtering to one class', () => {
|
||||
expect(
|
||||
filterSchedulesForClass([ownSchedule, otherClassBusyBlock, anotherOwnVisibleSchedule], 8),
|
||||
).toEqual([ownSchedule, otherClassBusyBlock]);
|
||||
});
|
||||
|
||||
it('keeps a rental schedule with explicit detail access unmasked', () => {
|
||||
expect(isMaskedSchedule({ id: 3, classId: null, canViewDetails: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullable IDs as masked for responses without an explicit detail flag', () => {
|
||||
expect(isMaskedSchedule({ id: null, classId: null })).toBe(true);
|
||||
expect(isMaskedSchedule(ownSchedule)).toBe(false);
|
||||
});
|
||||
});
|
||||
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface VisibleSchedule {
|
||||
id: number | null;
|
||||
classId: number | null;
|
||||
canViewDetails?: boolean;
|
||||
}
|
||||
|
||||
export const isMaskedSchedule = (
|
||||
schedule: Pick<VisibleSchedule, 'id' | 'classId' | 'canViewDetails'>,
|
||||
) =>
|
||||
schedule.canViewDetails === false ||
|
||||
(schedule.canViewDetails === undefined && (schedule.id === null || schedule.classId === null));
|
||||
|
||||
export const filterSchedulesForClass = <T extends VisibleSchedule>(
|
||||
schedules: T[],
|
||||
classId?: number,
|
||||
): T[] => {
|
||||
if (classId == null) return schedules;
|
||||
return schedules.filter((schedule) => isMaskedSchedule(schedule) || schedule.classId === classId);
|
||||
};
|
||||
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifySyncResult, errorSummary } from './sync-result';
|
||||
|
||||
describe('classifySyncResult', () => {
|
||||
const baseResult = {
|
||||
scheduleCount: 10,
|
||||
syncedItems: 50,
|
||||
skippedNoMapping: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
it('returns success when no failures', () => {
|
||||
const result = classifySyncResult(baseResult);
|
||||
expect(result.hasFailure).toBe(false);
|
||||
expect(result.level).toBe('success');
|
||||
expect(result.message).toContain('同步完成');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('returns warning when partial failure (some synced, some failed)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 20,
|
||||
errors: ['err1', 'err2'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
expect(result.message).toContain('30');
|
||||
expect(result.message).toContain('20');
|
||||
});
|
||||
|
||||
it('returns error when total failure (nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 50,
|
||||
errors: ['全部失败'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('classifies attendance group failure as failure, not success', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 45,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 5,
|
||||
errors: ['考勤组 排课_强化班 创建/更新失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
// must NOT be success level
|
||||
expect(result.level).not.toBe('success');
|
||||
});
|
||||
|
||||
it('returns error when all shifts failed (errors present, nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 10,
|
||||
errors: [
|
||||
'创建班次 排课_09:00-11:00 失败: permission denied',
|
||||
'创建班次 排课_14:00-16:00 失败: permission denied',
|
||||
],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
|
||||
it('returns warning when errors-only with some syncedItems (no failedBatchCount)', () => {
|
||||
// Edge case: errors from shift creation that don't bump failedBatchCount
|
||||
// but do leave errors[] non-empty, yet some items synced from other classes
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: network error'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
});
|
||||
|
||||
it('returns error when errors-only with zero syncedItems (no failedBatchCount)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorSummary', () => {
|
||||
it('returns all errors when 5 or fewer', () => {
|
||||
const errors = ['e1', 'e2', 'e3'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3']);
|
||||
});
|
||||
|
||||
it('returns first 5 when more than 5', () => {
|
||||
const errors = ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
||||
});
|
||||
|
||||
it('returns empty for empty errors', () => {
|
||||
expect(errorSummary([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** 同步结果分类 — 从组件中提取以便测试。 */
|
||||
|
||||
export interface SyncResultInput {
|
||||
scheduleCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface SyncResultClassification {
|
||||
/** 是否有任何失败 */
|
||||
hasFailure: boolean;
|
||||
/** 通知级别:'success' | 'warning' | 'error' */
|
||||
level: 'success' | 'warning' | 'error';
|
||||
/** 适合展示给用户的消息文本 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将同步结果分类为成功/部分失败/全部失败。
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems > 0 → warning(部分失败)
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems === 0 → error(全部失败)
|
||||
* - 否则 → success
|
||||
*/
|
||||
export function classifySyncResult(result: SyncResultInput): SyncResultClassification {
|
||||
const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;
|
||||
|
||||
if (!hasFailure) {
|
||||
return {
|
||||
hasFailure: false,
|
||||
level: 'success',
|
||||
message: `同步完成:${result.syncedItems} 条排班已写入钉钉`,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.syncedItems > 0) {
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'warning',
|
||||
message: `同步部分失败:${result.syncedItems} 条成功,${result.failedItems} 条失败`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'error',
|
||||
message: `同步失败:${result.failedItems} 条全部写入失败`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误列表中提取摘要(最多 5 条)。
|
||||
*/
|
||||
export function errorSummary(errors: string[]): string[] {
|
||||
return errors.slice(0, 5);
|
||||
}
|
||||
Reference in New Issue
Block a user