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

@@ -10,18 +10,16 @@ import {
Space,
Tag,
Popconfirm,
Tabs,
List,
Card,
Empty,
} from 'antd';
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
@@ -31,8 +29,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const refundStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待班主任审批', color: 'orange' },
head_teacher_approved: { text: '待财务审批', color: 'blue' },
pending: { text: '历史退款处理中', color: 'orange' },
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
finance_approved: { text: '已退款', color: 'green' },
refunded: { text: '已退款', color: 'green' },
};
@@ -42,17 +40,8 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
};
interface PendingRefund {
id: number;
student?: { name?: string };
amount?: number;
paidDate?: string;
refundStatus?: string;
refundRequestedAt?: string;
}
const DepositsPage: React.FC = () => {
const { hasPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@@ -60,16 +49,11 @@ const DepositsPage: React.FC = () => {
const [refundModal, setRefundModal] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [pendingRefunds, setPendingRefunds] = useState<PendingRefund[]>([]);
const [pendingLoading, setPendingLoading] = useState(false);
const [rejectModal, setRejectModal] = useState<any>(null);
const [rejectReason, setRejectReason] = useState('');
const [createForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [installmentForm] = Form.useForm();
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>('all');
const [saving, setSaving] = useState(false);
const fetchData = async () => {
@@ -77,7 +61,7 @@ const DepositsPage: React.FC = () => {
try {
const [d, s]: any[] = await Promise.all([
api.get('/deposits'),
hasPermission('deposit:create') ? api.get('/deposits/student-lookups') : Promise.resolve([]),
api.get('/deposits/student-lookups'),
]);
setData(d);
setStudents(s);
@@ -87,20 +71,11 @@ const DepositsPage: React.FC = () => {
setLoading(false);
};
const fetchPendingRefunds = async () => {
setPendingLoading(true);
try {
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
setPendingRefunds(res || []);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setPendingLoading(false);
};
useEffect(() => {
fetchData();
}, [hasPermission]);
}, []);
const filteredData = useMemo(() => {
return data.filter((d: any) => {
@@ -166,44 +141,6 @@ const DepositsPage: React.FC = () => {
}
};
const handleRequestRefund = async (record: any) => {
try {
await api.post(`/deposits/${record.id}/request-refund`);
message.success('退款申请已提交');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleApproveRefund = async (record: any) => {
try {
await api.put(`/deposits/${record.id}/approve-refund`);
message.success('审批通过');
fetchPendingRefunds();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleRejectRefund = async () => {
if (!rejectModal) return;
setSaving(true);
try {
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
message.success('已驳回退款申请');
setRejectModal(null);
setRejectReason('');
fetchPendingRefunds();
fetchData();
} catch (e: any) {
message.error(e?.message || '驳回失败');
} finally {
setSaving(false);
}
};
const handleAddInstallment = async () => {
if (installmentModal == null) return;
const values = await installmentForm.validateFields();
@@ -254,7 +191,7 @@ const DepositsPage: React.FC = () => {
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '退款审批',
title: '退款状态',
dataIndex: 'refundStatus',
render: (s: string) =>
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
@@ -289,7 +226,7 @@ const DepositsPage: React.FC = () => {
{record.status === 'paid' && !record.refundStatus && (
<>
<PermissionButton
permission="deposit:edit"
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
@@ -299,13 +236,6 @@ const DepositsPage: React.FC = () => {
>
退
</PermissionButton>
<PermissionButton
permission="deposit:edit"
size="small"
onClick={() => handleRequestRefund(record)}
>
退
</PermissionButton>
</>
)}
<Popconfirm
@@ -332,141 +262,67 @@ const DepositsPage: React.FC = () => {
</Space>
),
},
], [handleRequestRefund, fetchData]);
], [fetchData]);
const pendingColumns = [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
{
title: '审批状态', width: 120,
dataIndex: 'refundStatus',
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
},
{
title: '申请时间', width: 160,
dataIndex: 'refundRequestedAt',
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作', width: 200,
render: (_: unknown, record: PendingRefund) => (
<Space>
<PermissionButton
permission="deposit:approve"
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApproveRefund(record)}
>
</PermissionButton>
<PermissionButton
permission="deposit:approve"
size="small"
danger
onClick={() => {
setRejectModal(record);
setRejectReason('');
}}
>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<Tabs
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key);
if (key === 'pending') fetchPendingRefunds();
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
items={[
{
key: 'all',
label: '押金列表',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
]}
/>
</Space>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
</>
),
},
...(hasPermission('deposit:approve')
? [{
key: 'pending',
label: '待审批退款',
children: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
}]
: []),
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
]}
/>
</Space>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
{/* Create Modal */}
<Modal
@@ -556,7 +412,7 @@ const DepositsPage: React.FC = () => {
</p>
{detailModal.refundStatus && (
<p>
<strong>退:</strong>{' '}
<strong>退:</strong>{' '}
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
</Tag>
@@ -644,23 +500,7 @@ const DepositsPage: React.FC = () => {
</Form>
</Modal>
{/* Reject Refund Modal */}
<Modal
title={`驳回退款申请 - ${rejectModal?.student?.name || ''}`}
open={!!rejectModal}
onOk={handleRejectRefund}
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
okText="确认驳回"
okButtonProps={{ danger: true }}
confirmLoading={saving}
>
<Input.TextArea
aria-label="驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
/>
</Modal>
</div>
);
};