完善全业务边界校验与回归测试 #12

Merged
wangziqi merged 4 commits from codex/attendance-boundary-tests into main 2026-07-15 05:53:47 +00:00
76 changed files with 2837 additions and 421 deletions

View File

@@ -162,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [
{ value: 'other', label: '其他' }, { value: 'other', label: '其他' },
]; ];
const STUDENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
archived: { text: '已归档', color: '#999' },
};
const ENROLLMENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
active: { text: '报读中', color: 'green' },
completed: { text: '已结课', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
};
const COURSE_CATEGORY_OPTIONS = [ const COURSE_CATEGORY_OPTIONS = [
{ value: 'culture', label: '文化课' }, { value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' }, { value: 'professional', label: '专业课' },
@@ -176,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [
{ value: 'offline', label: '线下' }, { value: 'offline', label: '线下' },
]; ];
const getOptionLabel = (
options: Array<{ value: string; label: string }>,
value?: string | null,
): string => {
if (!value) return '-';
return options.find((option) => option.value === value)?.label || value;
};
const getCourseCategoryLabel = (value?: string | null): string =>
getOptionLabel(COURSE_CATEGORY_OPTIONS, value);
const getClassTypeLabel = (value?: string | null): string =>
getOptionLabel(CLASS_TYPE_OPTIONS, value);
const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => {
if (!value) return { text: '-', color: 'default' };
return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' };
};
const getStudentStatus = (value?: string | null): { text: string; color: string } => {
if (!value) return { text: '-', color: 'default' };
return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' };
};
const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
enrollment.className ||
(enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id));
const ATTACHMENT_CATEGORY_OPTIONS = [ const ATTACHMENT_CATEGORY_OPTIONS = [
{ value: 'id_card', label: '身份证' }, { value: 'id_card', label: '身份证' },
{ value: 'transcript', label: '成绩单' }, { value: 'transcript', label: '成绩单' },
@@ -350,8 +391,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
}; };
const columns: ColumnsType<EnrollmentRecord> = [ const columns: ColumnsType<EnrollmentRecord> = [
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' }, { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' }, { title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
@@ -361,12 +402,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
render: (v: string) => { render: (v: string) => {
const colorMap: Record<string, string> = { const status = getEnrollmentStatus(v);
active: 'green', return <Tag color={status.color}>{status.text}</Tag>;
completed: 'blue',
withdrawn: 'red',
};
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
}, },
}, },
]; ];
@@ -477,7 +514,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
render: (v: number | undefined) => { render: (v: number | undefined) => {
if (v === undefined) return '-'; if (v === undefined) return '-';
const enr = enrollments.find((e) => e.id === v); const enr = enrollments.find((e) => e.id === v);
return enr ? `${enr.className || enr.courseCategory || v}` : String(v); return enr ? formatEnrollmentDisplayName(enr) : String(v);
}, },
}, },
]; ];
@@ -540,7 +577,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
placeholder="选择关联的报读记录" placeholder="选择关联的报读记录"
options={enrollments.map((e) => ({ options={enrollments.map((e) => ({
value: e.id, value: e.id,
label: `${e.className || e.courseCategory || e.id} (${e.classType})`, label: `${formatEnrollmentDisplayName(e)}${getClassTypeLabel(e.classType)}`,
}))} }))}
/> />
</Form.Item> </Form.Item>
@@ -976,7 +1013,10 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
) : '-'} ) : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
<Tag>{student.status || '-'}</Tag> {(() => {
const status = getStudentStatus(student.status);
return <Tag color={status.color}>{status.text}</Tag>;
})()}
</Descriptions.Item> </Descriptions.Item>
{profile?.targetCollege && ( {profile?.targetCollege && (
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item> <Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
canPullAttendance, canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience, getAttendanceExperience,
getPunchDisplayInfo, getPunchDisplayInfo,
getSchedulePhase, getSchedulePhase,
@@ -68,6 +69,27 @@ describe('lesson check-in summary', () => {
}); });
describe('lesson attendance filters', () => {
const records = [
{ id: 1, student: { name: '张三' }, status: 'present' },
{ id: 2, student: { name: '李四' }, status: 'late' },
{ id: 3, student: { name: '王五' }, status: 'pending' },
{ id: 4, student: { name: '赵六' }, status: 'absent' },
];
it('searches students by name and ignores surrounding whitespace', () => {
expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]);
});
it('groups present and late as checked in', () => {
expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]);
});
it('groups pending and absent as not checked in and combines with search', () => {
expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]);
});
});
describe('lesson punch device display', () => { describe('lesson punch device display', () => {
it('labels attendance machine punches with the machine name and id', () => { it('labels attendance machine punches with the machine name and id', () => {
expect( expect(

View File

@@ -84,6 +84,30 @@ export function summarizeLessonCheckins(
} }
export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in';
export interface LessonAttendanceFilterRecord {
student: { name: string };
status: string;
}
export function filterLessonAttendanceRecords<T extends LessonAttendanceFilterRecord>(
records: readonly T[],
keyword: string,
filter: LessonAttendanceFilter,
): T[] {
const normalizedKeyword = keyword.trim().toLocaleLowerCase('zh-CN');
return records.filter((record) => {
const matchesKeyword =
!normalizedKeyword ||
record.student.name.toLocaleLowerCase('zh-CN').includes(normalizedKeyword);
if (!matchesKeyword || filter === 'all') return matchesKeyword;
const checkedIn = record.status === 'present' || record.status === 'late';
return filter === 'checked_in' ? checkedIn : !checkedIn;
});
}
export interface PunchDisplayRecord { export interface PunchDisplayRecord {
status: string; status: string;
source?: string; source?: string;

View File

@@ -286,6 +286,32 @@
.is-leave { color: #2874c6 !important; background: #edf5ff; } .is-leave { color: #2874c6 !important; background: #edf5ff; }
.is-pending { color: #667085 !important; background: #f1f3f6; } .is-pending { color: #667085 !important; background: #f1f3f6; }
.lesson-record-filters {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #f8fafc;
}
.lesson-record-search {
width: 260px;
}
.lesson-record-filter-select {
width: 130px;
}
.lesson-record-filter-count {
margin-left: auto;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.attendance-status { .attendance-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -414,6 +440,22 @@
color: #a1a9b5; color: #a1a9b5;
} }
@media (max-width: 640px) {
.lesson-record-filters {
align-items: stretch;
flex-direction: column;
}
.lesson-record-search,
.lesson-record-filter-select {
width: 100%;
}
.lesson-record-filter-count {
margin-left: 0;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.attendance-hero, .attendance-hero,
.archive-toolbar { .archive-toolbar {

View File

@@ -40,11 +40,13 @@ import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { import {
canPullAttendance, canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience, getAttendanceExperience,
getPunchDisplayInfo, getPunchDisplayInfo,
getSchedulePhase, getSchedulePhase,
summarizeLessonCheckins, summarizeLessonCheckins,
type AttendanceSummary, type AttendanceSummary,
type LessonAttendanceFilter,
type SchedulePhase, type SchedulePhase,
} from './attendance-workspace'; } from './attendance-workspace';
import './attendance.css'; import './attendance.css';
@@ -261,6 +263,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]); const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false); const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
const loadWorkspace = useCallback(async () => { const loadWorkspace = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -284,6 +288,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const openAttendance = useCallback(async (schedule: TodaySchedule) => { const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
setSelectedSchedule(schedule); setSelectedSchedule(schedule);
setDrawerOpen(true); setDrawerOpen(true);
setRecordLoading(true); setRecordLoading(true);
@@ -334,6 +340,10 @@ const TeacherAttendanceWorkspace: React.FC = () => {
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
); );
const isAttendanceCompleted = lessonSession?.status === 'completed'; const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
[lessonRecords, studentKeyword, checkinFilter],
);
return ( return (
<div className="attendance-page teacher-attendance"> <div className="attendance-page teacher-attendance">
@@ -407,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => {
/> />
)} )}
<LessonCheckinSummaryStrip records={lessonRecords} /> <LessonCheckinSummaryStrip records={lessonRecords} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={studentKeyword}
placeholder="搜索学生姓名"
onChange={(event) => setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={checkinFilter}
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredLessonRecords.length} / {lessonRecords.length}
</span>
</div>
<Table<AttendanceRecordItem> <Table<AttendanceRecordItem>
rowKey="id" rowKey="id"
loading={recordLoading} loading={recordLoading}
dataSource={lessonRecords} dataSource={filteredLessonRecords}
pagination={false} pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本节课尚未开始点名" /> }} locale={{
emptyText: <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>,
}}
columns={[ columns={[
{ {
title: '学生', dataIndex: ['student', 'name'], title: '学生', dataIndex: ['student', 'name'],

View File

@@ -88,6 +88,7 @@ const RoomsPage: React.FC = () => {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined); const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterRentalCategory, setFilterRentalCategory] = useState<string | undefined>(undefined);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -154,8 +155,11 @@ const RoomsPage: React.FC = () => {
} }
if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding); if (filterBuilding) result = result.filter((r: Record<string, unknown>) => r.building === filterBuilding);
if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus); if (filterStatus) result = result.filter((r: Record<string, unknown>) => r.status === filterStatus);
if (filterRentalCategory) {
result = result.filter((r: Record<string, unknown>) => r.rentalCategory === filterRentalCategory);
}
return result; return result;
}, [data, searchText, filterBuilding, filterStatus]); }, [data, searchText, filterBuilding, filterStatus, filterRentalCategory]);
const remainingBedSlots = useMemo(() => { const remainingBedSlots = useMemo(() => {
const capacity = Number(drawerRoom?.capacity) || 0; const capacity = Number(drawerRoom?.capacity) || 0;
return Math.max(capacity - beds.length, 0); return Math.max(capacity - beds.length, 0);
@@ -434,6 +438,17 @@ const RoomsPage: React.FC = () => {
/> />
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus} <Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} /> options={[{value:'available',label:'可入住'},{value:'full',label:'已满'},{value:'maintenance',label:'维护中'}]} />
<Select
placeholder="租赁类型"
allowClear
style={{ width: 120 }}
value={filterRentalCategory}
onChange={setFilterRentalCategory}
options={[
{ value: 'long', label: '长租' },
{ value: 'short', label: '短租' },
]}
/>
<Button <Button
type={showArchived ? 'primary' : 'default'} type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)} onClick={() => setShowArchived(!showArchived)}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert,
App, App,
Button, Button,
Card, Card,
@@ -62,6 +63,19 @@ interface EnrollmentInfo {
}; };
} }
interface StudentCreateImportResult {
message?: string;
imported?: number;
skipped?: number;
}
interface StudentUpdateImportResult {
message?: string;
matched?: number;
skipped?: number;
}
const StudentsPage: React.FC = () => { const StudentsPage: React.FC = () => {
const { modal } = App.useApp(); const { modal } = App.useApp();
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
@@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('下载失败')); .catch(() => message.error('下载失败'));
}; };
const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const showCreateImportResult = (result: StudentCreateImportResult) => {
const imported = result.imported ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '导入完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功新增">{imported} </Descriptions.Item>
<Descriptions.Item label="跳过">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
<li></li>
<li></li>
</ul>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
const showUpdateImportResult = (result: StudentUpdateImportResult) => {
const matched = result.matched ?? 0;
const skipped = result.skipped ?? 0;
modal.success({
title: '更新完成',
okText: '知道了',
content: (
<div>
<Descriptions column={1} size="small">
<Descriptions.Item label="成功更新">{matched} </Descriptions.Item>
<Descriptions.Item label="未匹配">{skipped} </Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 12, fontWeight: 600 }}></div>
<div></div>
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
</div>
</div>
),
});
};
const handleCreateStudentsImport: UploadProps['customRequest'] = async ({
file,
onSuccess,
onError,
}) => {
const formData = new FormData();
formData.append('file', file as File);
try {
const res = (await api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})) as StudentCreateImportResult;
showCreateImportResult(res);
onSuccess?.(res);
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败'));
}
};
const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({
file,
onSuccess,
onError,
}) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file as File); formData.append('file', file as File);
try { try {
const res = (await api.post('/students/import-match', formData, { const res = (await api.post('/students/import-match', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
})) as { message: string }; })) as StudentUpdateImportResult;
message.success(res.message); showUpdateImportResult(res);
onSuccess?.(res); onSuccess?.(res);
fetchData(); fetchData();
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string }; const err = e as { message?: string };
message.error(err?.message || '匹配导入失败'); message.error(err?.message || '更新已有学生资料失败');
onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败')); onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败'));
} }
}; };
@@ -277,12 +365,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span> <span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '电话', v)} onClick={() => handleViewSensitive(record.id, '电话', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -305,12 +393,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span> <span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '身份证号', v)} onClick={() => handleViewSensitive(record.id, '身份证号', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -329,12 +417,12 @@ const StudentsPage: React.FC = () => {
render: (v: string, record: any) => { render: (v: string, record: any) => {
if (!v) return '-'; if (!v) return '-';
return ( return (
<span> <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span> <span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
<Button <Button
type="link" type="link"
size="small" size="small"
style={{ padding: '8px 4px' }} style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)} onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
title="点击查看完整号码" title="点击查看完整号码"
> >
@@ -530,29 +618,15 @@ const StudentsPage: React.FC = () => {
> >
</PermissionButton> </PermissionButton>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleCreateStudentsImport}>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
<Upload <Upload
accept=".xlsx,.xls" accept=".xlsx,.xls"
showUploadList={false} showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => { customRequest={handleUpdateExistingStudentsImport}
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
}
}}
> >
<Button icon={<UploadOutlined />}>Excel</Button> <Button icon={<SwapOutlined />}></Button>
</Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
<Button icon={<SwapOutlined />}></Button>
</Upload> </Upload>
<PermissionButton <PermissionButton
permission="student:view" permission="student:view"
@@ -570,6 +644,17 @@ const StudentsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
</Space> </Space>
</div> </div>
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message={
<span>
<strong></strong>Excel
</span>
}
/>
<Table <Table
columns={columns} columns={columns}
dataSource={data} dataSource={data}

View File

@@ -0,0 +1,102 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ArchiveService } from './archive.service';
function createService(repos: Partial<Record<string, Record<string, jest.Mock>>> = {}) {
return new ArchiveService(
(repos.student ?? {}) as never,
(repos.profile ?? {}) as never,
(repos.enrollment ?? {}) as never,
(repos.exam ?? {}) as never,
(repos.learning ?? {}) as never,
(repos.result ?? {}) as never,
(repos.attachment ?? {}) as never,
(repos.attendance ?? {}) as never,
{} as never,
);
}
describe('ArchiveService — resource and relationship boundaries', () => {
it('rejects adding archive records for a missing student', async () => {
const student = { findOne: jest.fn().mockResolvedValue(null) };
const service = createService({ student });
await expect(
service.addEnrollment(404, { courseCategory: '文化', classType: '冲刺' }),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
service.addLearningRecord(404, {
recordDate: '2026-07-14',
recordType: '沟通',
content: '内容',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects linking an exam score to another student enrollment', async () => {
const exam = { create: jest.fn(), save: jest.fn() };
const service = createService({
student: { findOne: jest.fn().mockResolvedValue({ id: 7 }) },
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam,
});
await expect(
service.addExamScore(7, {
examType: '月考',
subject: '语文',
score: 90,
enrollmentId: 99,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(exam.save).not.toHaveBeenCalled();
});
it('rejects moving an existing exam score to another student enrollment', async () => {
const exam = {
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, enrollmentId: 1 }),
save: jest.fn(),
};
const service = createService({
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam,
});
await expect(service.updateExamScore(3, { enrollmentId: 99 })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(exam.save).not.toHaveBeenCalled();
});
it('rejects a missing attachment upload before writing to disk', async () => {
const service = createService({ student: { findOne: jest.fn() } });
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects attachment path traversal', async () => {
const service = createService({
attachment: {
findOne: jest.fn().mockResolvedValue({
id: 1,
studentId: 7,
filePath: '../../etc/passwd',
}),
},
});
await expect(service.getAttachmentFile(7, 1)).rejects.toBeInstanceOf(BadRequestException);
});
it('returns not found for update/delete of absent child records', async () => {
const service = createService({
enrollment: { findOne: jest.fn().mockResolvedValue(null) },
exam: { findOne: jest.fn().mockResolvedValue(null) },
learning: { findOne: jest.fn().mockResolvedValue(null) },
attachment: { findOne: jest.fn().mockResolvedValue(null) },
});
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteExamScore(1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteLearningRecord(1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteAttachment(1)).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -61,20 +61,27 @@ export class ArchiveService {
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances] = const [
await Promise.all([ profileRaw,
this.profileRepo.findOne({ where: { studentId } }), enrollments,
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), examScores,
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), learningRecords,
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), resultArchive,
this.resultRepo.findOne({ where: { studentId } }), attachments,
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), attendances,
this.attendanceRepo.find({ ] = await Promise.all([
where: { studentId }, this.profileRepo.findOne({ where: { studentId } }),
relations: ['schedule', 'class'], this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
order: { attendanceDate: 'DESC', punchTime: 'DESC' }, this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
}), this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
]); this.resultRepo.findOne({ where: { studentId } }),
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
this.attendanceRepo.find({
where: { studentId },
relations: ['schedule', 'class'],
order: { attendanceDate: 'DESC', punchTime: 'DESC' },
}),
]);
return { return {
student, student,
@@ -123,9 +130,18 @@ export class ArchiveService {
return { message: '已删除' }; return { message: '已删除' };
} }
private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) {
if (enrollmentId === undefined) return;
const enrollment = await this.enrollmentRepo.findOne({
where: { id: enrollmentId, studentId },
});
if (!enrollment) throw new BadRequestException('报名记录不属于该学生');
}
async addExamScore(studentId: number, dto: CreateExamScoreDto) { async addExamScore(studentId: number, dto: CreateExamScoreDto) {
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId);
const entity = this.examScoreRepo.create({ ...dto, studentId }); const entity = this.examScoreRepo.create({ ...dto, studentId });
return this.examScoreRepo.save(entity); return this.examScoreRepo.save(entity);
@@ -134,6 +150,7 @@ export class ArchiveService {
async updateExamScore(id: number, dto: UpdateExamScoreDto) { async updateExamScore(id: number, dto: UpdateExamScoreDto) {
const entity = await this.examScoreRepo.findOne({ where: { id } }); const entity = await this.examScoreRepo.findOne({ where: { id } });
if (!entity) throw new NotFoundException('考试成绩不存在'); if (!entity) throw new NotFoundException('考试成绩不存在');
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
Object.assign(entity, dto); Object.assign(entity, dto);
return this.examScoreRepo.save(entity); return this.examScoreRepo.save(entity);
} }
@@ -181,6 +198,8 @@ export class ArchiveService {
} }
async addAttachment(studentId: number, file: Express.Multer.File, category: string) { async addAttachment(studentId: number, file: Express.Multer.File, category: string) {
if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件');
const student = await this.studentRepo.findOne({ where: { id: studentId } }); const student = await this.studentRepo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');

View File

@@ -1,5 +1,5 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
export class UpsertProfileDto { export class UpsertProfileDto {
@IsOptional() @IsString() targetCollege?: string; @IsOptional() @IsString() targetCollege?: string;
@@ -11,8 +11,8 @@ export class UpsertProfileDto {
} }
export class CreateEnrollmentDto { export class CreateEnrollmentDto {
@IsString() courseCategory: string; @IsString() @IsNotEmpty() courseCategory: string;
@IsString() classType: string; @IsString() @IsNotEmpty() classType: string;
@IsOptional() @IsString() className?: string; @IsOptional() @IsString() className?: string;
@IsOptional() @IsString() headTeacher?: string; @IsOptional() @IsString() headTeacher?: string;
@IsOptional() @IsString() subjectTeacher?: string; @IsOptional() @IsString() subjectTeacher?: string;
@@ -24,12 +24,12 @@ export class CreateEnrollmentDto {
export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {} export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}
export class CreateExamScoreDto { export class CreateExamScoreDto {
@IsString() examType: string; @IsString() @IsNotEmpty() examType: string;
@IsOptional() @IsString() examName?: string; @IsOptional() @IsString() examName?: string;
@IsString() subject: string; @IsString() @IsNotEmpty() subject: string;
@IsNumber() score: number; @IsNumber() @Min(0) score: number;
@IsOptional() @IsNumber() classAvg?: number; @IsOptional() @IsNumber() @Min(0) classAvg?: number;
@IsOptional() @IsNumber() rank?: number; @IsOptional() @IsNumber() @Min(1) rank?: number;
@IsOptional() @IsDateString() examDate?: string; @IsOptional() @IsDateString() examDate?: string;
@IsOptional() @IsNumber() enrollmentId?: number; @IsOptional() @IsNumber() enrollmentId?: number;
} }
@@ -38,8 +38,8 @@ export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {}
export class CreateLearningRecordDto { export class CreateLearningRecordDto {
@IsDateString() recordDate: string; @IsDateString() recordDate: string;
@IsString() recordType: string; @IsString() @IsNotEmpty() recordType: string;
@IsString() content: string; @IsString() @IsNotEmpty() content: string;
@IsOptional() @IsString() followUpMethod?: string; @IsOptional() @IsString() followUpMethod?: string;
@IsOptional() @IsString() nextStep?: string; @IsOptional() @IsString() nextStep?: string;
} }
@@ -47,8 +47,8 @@ export class CreateLearningRecordDto {
export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {} export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {}
export class UpsertResultDto { export class UpsertResultDto {
@IsOptional() @IsNumber() cultureFinalScore?: number; @IsOptional() @IsNumber() @Min(0) cultureFinalScore?: number;
@IsOptional() @IsNumber() professionalFinalScore?: number; @IsOptional() @IsNumber() @Min(0) professionalFinalScore?: number;
@IsOptional() @IsString() admissionStatus?: string; @IsOptional() @IsString() admissionStatus?: string;
@IsOptional() @IsString() admittedCollege?: string; @IsOptional() @IsString() admittedCollege?: string;
@IsOptional() @IsString() admittedMajor?: string; @IsOptional() @IsString() admittedMajor?: string;

View File

@@ -80,6 +80,49 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
}); });
it('does not settle an in-progress lesson before its end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an in-progress lesson when its end time is reached', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T10:00:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledTimes(1);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
it('continues with the next lesson when one settlement fails', async () => { it('continues with the next lesson when one settlement fails', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]); scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
@@ -163,6 +206,32 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
}); });
it('does not settle an in-progress overnight lesson before its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
const overnightSchedule = {
...schedule,
id: 4,
weekDay: 7,
startTime: '22:00',
endTime: '01:00',
};
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
id: 91,
scheduleId: 4,
lessonDate: '2026-07-12',
status: 'in_progress',
schedule: overnightSchedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-13T00:30:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an overnight lesson after its next-day end time', async () => { it('settles an overnight lesson after its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([ scheduleRepo.find.mockResolvedValue([

View File

@@ -61,7 +61,11 @@ export class AttendanceSettlementService {
} }
} }
for (const session of sessions) { for (const session of sessions) {
if (session.status === 'in_progress' && session.schedule) { if (
session.status === 'in_progress' &&
session.schedule &&
this.hasOccurrenceEnded(session.schedule, session.lessonDate, clock)
) {
candidates.set(`${session.scheduleId}|${session.lessonDate}`, { candidates.set(`${session.scheduleId}|${session.lessonDate}`, {
schedule: session.schedule, schedule: session.schedule,
lessonDate: session.lessonDate, lessonDate: session.lessonDate,
@@ -163,6 +167,18 @@ export class AttendanceSettlementService {
return null; return null;
} }
private hasOccurrenceEnded(
schedule: ClassSchedule,
lessonDate: string,
clock: { date: string; minutes: number },
): boolean {
const occurrenceEndDate = this.isOvernight(schedule)
? this.shiftDate(lessonDate, 1)
: lessonDate;
if (clock.date !== occurrenceEndDate) return clock.date > occurrenceEndDate;
return clock.minutes >= this.toMinutes(schedule.endTime);
}
private isOvernight(schedule: ClassSchedule): boolean { private isOvernight(schedule: ClassSchedule): boolean {
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime); return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
} }

View File

@@ -593,3 +593,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(result.source).toBe('manual'); expect(result.source).toBe('manual');
}); });
}); });
describe('AttendanceService — attendance window boundaries', () => {
it('crosses calendar boundaries only when the window requires it', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
});
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
const originalTz = process.env.TZ;
process.env.TZ = 'UTC';
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
try {
const { service, scheduleRepo, sessionRepo, attendanceRepo } = createService();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13',
});
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
attendanceRepo.find.mockResolvedValue([]);
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
.resolves.toMatchObject({ records: [] });
} finally {
jest.useRealTimers();
process.env.TZ = originalTz;
}
});
});

View File

@@ -276,16 +276,13 @@ export class AttendanceService {
) { ) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date(); const now = new Date();
const today = [ const courseClock = this.getCourseClock(now);
now.getFullYear(), const today = courseClock.date;
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤'); if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) { if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number); const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute; const startMinute = hour * 60 + minute;
const currentMinute = now.getHours() * 60 + now.getMinutes(); const currentMinute = courseClock.minutes;
if (currentMinute < startMinute) { if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤'); throw new BadRequestException('课程尚未开始,不能拉取考勤');
} }
@@ -676,6 +673,20 @@ export class AttendanceService {
return hour * 60 + minute; return hour * 60 + minute;
} }
private getCourseClock(date: Date): { date: string; minutes: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
minutes: Number(parts.hour) * 60 + Number(parts.minute),
};
}
private shiftDate(date: string, days: number): string { private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`); const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days); shifted.setUTCDate(shifted.getUTCDate() + days);

View File

@@ -1,7 +1,7 @@
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
describe('AuthService — super admin identity', () => { describe('AuthService — authentication boundaries', () => {
it('marks the preset 超管 role as super admin in the JWT payload', async () => { it('marks the preset 超管 role as super admin in the JWT payload', async () => {
const userRepo = { const userRepo = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
@@ -22,8 +22,29 @@ describe('AuthService — super admin identity', () => {
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1'); await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
expect(jwtService.sign).toHaveBeenCalledWith( expect(jwtService.sign).toHaveBeenCalledWith(expect.objectContaining({ isSuperAdmin: true }));
expect.objectContaining({ isSuperAdmin: true }), });
it('rejects an archived user even when the password is valid', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 2,
username: 'archived',
passwordHash: await bcrypt.hash('secret', 4),
isActive: true,
isArchived: true,
roles: [],
}),
save: jest.fn(),
};
const service = new AuthService(
userRepo as never,
{ sign: jest.fn() } as never,
{ getUserPermissions: jest.fn() } as never,
); );
await expect(
service.login({ username: 'archived', password: 'secret' }, '192.0.2.10'),
).rejects.toThrow('账号已失效');
expect(userRepo.save).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -38,7 +38,9 @@ export class AuthService {
this.recordFailedAttempt(attemptKey); this.recordFailedAttempt(attemptKey);
throw new UnauthorizedException('用户名或密码错误'); throw new UnauthorizedException('用户名或密码错误');
} }
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员'); if (!user.isActive || user.isArchived) {
throw new UnauthorizedException('账号已失效,请联系管理员');
}
const valid = await bcrypt.compare(dto.password, user.passwordHash); const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) { if (!valid) {
this.recordFailedAttempt(attemptKey); this.recordFailedAttempt(attemptKey);

View File

@@ -33,6 +33,23 @@ describe('JwtStrategy', () => {
}); });
}); });
it('recognizes the canonical super_admin role code even when the display name changes', async () => {
const userRepo = {
findOne: jest.fn().mockResolvedValue({
id: 1,
username: 'admin',
isActive: true,
isArchived: false,
roles: [{ name: '系统管理员', code: 'super_admin', status: 1, permissions: [] }],
}),
};
const strategy = new JwtStrategy(config as never, userRepo as never);
await expect(strategy.validate({ sub: 1 })).resolves.toEqual(
expect.objectContaining({ isSuperAdmin: true }),
);
});
it.each([ it.each([
[{ id: 7, isActive: false, isArchived: false, roles: [] }], [{ id: 7, isActive: false, isArchived: false, roles: [] }],
[{ id: 7, isActive: true, isArchived: true, roles: [] }], [{ id: 7, isActive: true, isArchived: true, roles: [] }],

View File

@@ -47,7 +47,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
for (const role of user.roles ?? []) { for (const role of user.roles ?? []) {
if (role.status !== 1) continue; if (role.status !== 1) continue;
roles.push(role.name); roles.push(role.name);
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true; if (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin') {
isSuperAdmin = true;
}
for (const permission of role.permissions ?? []) permissions.add(permission.code); for (const permission of role.permissions ?? []) permissions.add(permission.code);
} }

View File

@@ -0,0 +1,77 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BillsService } from './bills.service';
import { Bill } from '../entities/bill.entity';
function queryBuilder() {
return {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
}
function createService(bills: Partial<Bill>[] = []) {
const billRepo = {
find: jest.fn().mockResolvedValue(bills),
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
save: jest.fn(async (value) => value),
createQueryBuilder: jest.fn(() => queryBuilder()),
};
const manager = {
delete: jest.fn(),
update: jest.fn(),
};
const dataSource = { transaction: jest.fn(async (callback) => callback(manager)) };
const service = new BillsService(
billRepo as any,
{ delete: jest.fn() } as any,
{} as any,
{ update: jest.fn() } as any,
{} as any,
{} as any,
dataSource as any,
{} as any,
);
return { service, billRepo, dataSource, manager };
}
describe('BillsService state and batch boundaries', () => {
it('rejects an empty batch status update', async () => {
const { service, billRepo } = createService();
await expect(service.batchUpdateStatus([], 'paid')).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.find).not.toHaveBeenCalled();
});
it('rejects a batch status update when some ids do not exist', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 0, outstandingAmount: 10 }]);
await expect(service.batchUpdateStatus([1, 2], 'unpaid')).rejects.toBeInstanceOf(NotFoundException);
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('rejects marking a partially paid bill unpaid', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 10, outstandingAmount: 90, status: 'partially_paid' }]);
await expect(service.updateStatus(1, { status: 'unpaid' })).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.save).not.toHaveBeenCalled();
});
it('rejects an empty batch delete', async () => {
const { service, dataSource } = createService();
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a batch delete when some ids do not exist', async () => {
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes a bill and its links in one transaction', async () => {
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.delete).toHaveBeenCalledTimes(2);
expect(manager.update).toHaveBeenCalledTimes(1);
});
});

View File

@@ -544,3 +544,52 @@ describe('BillsService — generateBills', () => {
expect(result.count).toBe(0); expect(result.count).toBe(0);
}); });
}); });
describe('BillsService — allocation rounding boundary', () => {
it('keeps allocated cents equal to the original expense total', async () => {
const billRepo = mockRepo<Bill>();
const itemRepo = mockRepo<BillItem>();
const roomExpRepo = mockRepo<RoomExpense>();
const personalExpRepo = mockRepo<PersonalExpense>();
const occRepo = mockRepo<Occupancy>();
const roomRepo = mockRepo<Room>();
let nextBillId = 0;
const dataSource = {
query: jest.fn().mockResolvedValue([]),
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: any) => value,
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
createQueryBuilder: jest.fn(() => ({
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
})),
})),
};
const service = new BillsService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,
]));
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<Occupancy>([
{ id: 1, roomId: 1, studentId: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
]));
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<PersonalExpense>([]));
const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any);
expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]);
expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100);
});
});

View File

@@ -32,8 +32,11 @@ export class BillsService {
const { periodStart, periodEnd } = dto.billingMonth const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth) ? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
const pStart = new Date(periodStart); if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
const pEnd = new Date(periodEnd); throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
}
const pStart = new Date(`${periodStart}T00:00:00Z`);
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) { if (existingBills.length > 0) {
@@ -139,11 +142,16 @@ export class BillsService {
if (totalDays === 0) continue; if (totalDays === 0) continue;
// 对每项费用进行分摊 // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
for (const expense of expenses) { for (const expense of expenses) {
for (const sd of studentDays) { const eligibleDays = studentDays.filter((sd) => sd.days > 0);
if (sd.days === 0) continue; const expenseTotal = Number(Number(expense.amount).toFixed(2));
const amount = Number(((sd.days / totalDays) * Number(expense.amount)).toFixed(2)); let allocated = 0;
for (const [index, sd] of eligibleDays.entries()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
if (!studentBillData.has(sd.studentId)) { if (!studentBillData.has(sd.studentId)) {
studentBillData.set(sd.studentId, { shared: 0, items: [] }); studentBillData.set(sd.studentId, { shared: 0, items: [] });
} }
@@ -189,16 +197,14 @@ export class BillsService {
} }
// 合并所有涉及的学生 // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
// 生成账单 const bills = await this.dataSource.transaction(async (manager) => {
const bills: Bill[] = []; const generated: Bill[] = [];
for (const studentId of allStudentIds) { for (const studentId of allStudentIds) {
const shared = studentBillData.get(studentId)?.shared || 0; const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0; const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2)); const total = Number((shared + personal).toFixed(2));
const savedBill = await this.dataSource.transaction(async (manager) => {
let bill = await manager.save( let bill = await manager.save(
manager.create(Bill, { manager.create(Bill, {
studentId, studentId,
@@ -230,14 +236,20 @@ export class BillsService {
.execute(); .execute();
} }
bill = await this.walletsService.debitBill(manager, bill); bill = await this.walletsService.debitBill(manager, bill);
return bill; generated.push(bill);
}); }
bills.push(savedBill); return generated;
} });
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
} }
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
private resolveBillingPeriod(billingMonth: string) { private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || ''); const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM'); if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
@@ -344,34 +356,36 @@ export class BillsService {
async updateStatus(id: number, dto: UpdateBillStatusDto) { async updateStatus(id: number, dto: UpdateBillStatusDto) {
const bill = await this.billRepo.findOne({ where: { id } }); const bill = await this.billRepo.findOne({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) { this.assertStatusMatchesAmounts(bill, dto.status);
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
}
bill.status = dto.status; bill.status = dto.status;
return this.billRepo.save(bill); return this.billRepo.save(bill);
} }
async batchUpdateStatus(ids: number[], status: string) { async batchUpdateStatus(ids: number[], status: string) {
const bills = await this.billRepo.find({ where: { id: In(ids) } }); const uniqueIds = [...new Set(ids || [])];
if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) { if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单');
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付'); if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效');
} const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
for (const bill of bills) this.assertStatusMatchesAmounts(bill, status);
await this.billRepo await this.billRepo
.createQueryBuilder() .createQueryBuilder()
.update() .update()
.set({ status }) .set({ status })
.where('id IN (:...ids)', { ids }) .where('id IN (:...ids)', { ids: uniqueIds })
.execute(); .execute();
return { message: `成功更新 ${ids.length} 条账单状态` }; return { message: `成功更新 ${uniqueIds.length} 条账单状态` };
} }
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const reason = dto.reason?.trim();
if (!reason) throw new BadRequestException('取消原因不能为空');
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const bill = await manager.findOne(Bill, { where: { id } }); const bill = await manager.findOne(Bill, { where: { id } });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null }); await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy); return this.walletsService.refundBill(manager, bill, reason, recordedBy);
}); });
} }
@@ -381,25 +395,38 @@ export class BillsService {
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') { if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单'); throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
} }
await this.itemRepo.delete({ billId: id }); await this.dataSource.transaction(async (manager) => {
await this.personalExpRepo.update({ billId: id }, { billId: null }); await manager.delete(BillItem, { billId: id });
await this.billRepo.delete(id); await manager.update(PersonalExpense, { billId: id }, { billId: null });
await manager.delete(Bill, id);
});
return { message: '账单已删除' }; return { message: '账单已删除' };
} }
async batchRemove(ids: number[]) { async batchRemove(ids: number[]) {
const bills = await this.billRepo.find({ where: { id: In(ids) } }); const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) { if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
throw new BadRequestException('选中账单包含资金流水,不能批量删除'); throw new BadRequestException('选中账单包含资金流水,不能批量删除');
} }
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute(); await this.dataSource.transaction(async (manager) => {
await this.personalExpRepo await manager.delete(BillItem, { billId: In(uniqueIds) });
.createQueryBuilder() await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
.update() await manager.delete(Bill, uniqueIds);
.set({ billId: null }) });
.where('billId IN (:...ids)', { ids }) return { message: `成功删除 ${uniqueIds.length} 条账单` };
.execute(); }
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();
return { message: `成功删除 ${ids.length} 条账单` }; private assertStatusMatchesAmounts(bill: Bill, status: string) {
const paid = Number(bill.paidAmount || 0);
const outstanding = Number(bill.outstandingAmount || 0);
const matches = status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
} }
} }

View File

@@ -1,4 +1,4 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto { export class GenerateBillsDto {
@IsString() @IsString()
@@ -21,6 +21,8 @@ export class UpdateBillStatusDto {
export class CancelBillDto { export class CancelBillDto {
@IsString() @IsString()
@IsNotEmpty()
@Matches(/\S/)
@MaxLength(300) @MaxLength(300)
reason: string; reason: string;
} }

View File

@@ -0,0 +1,69 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ClassesService } from './classes.service';
function createService(classRepo: Record<string, jest.Mock>, classTeacherRepo = {}) {
return new ClassesService(
classRepo as never,
{} as never,
classTeacherRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}
describe('ClassesService — archive and teacher boundaries', () => {
it('rejects repeated archive and restore operations', async () => {
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: true }) }).archive(1),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
createService({ findOne: jest.fn().mockResolvedValue({ isArchived: false }) }).restore(1),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects assigning a teacher to a missing class', async () => {
const classTeacherRepo = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() };
await expect(
createService({ findOne: jest.fn().mockResolvedValue(null) }, classTeacherRepo).addTeacher(
9,
{
userId: 2,
roleType: 'head_teacher',
},
),
).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.save).not.toHaveBeenCalled();
});
it('rejects duplicate teacher roles', async () => {
const classTeacherRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3 }),
create: jest.fn(),
save: jest.fn(),
};
await expect(
createService(
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) },
classTeacherRepo,
).addTeacher(1, {
userId: 2,
roleType: 'head_teacher',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects removing a teacher or assignment that is not in the class', async () => {
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
delete: jest.fn(),
};
const service = createService({}, classTeacherRepo);
await expect(service.removeTeacher(1, 2)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.removeTeacherAssignment(1, 3)).rejects.toBeInstanceOf(NotFoundException);
expect(classTeacherRepo.delete).not.toHaveBeenCalled();
});
});

View File

@@ -41,7 +41,10 @@ describe('ClassesService — teacher data scope', () => {
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => { it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
const classRepo = { update: jest.fn() }; const classRepo = { update: jest.fn() };
const classTeacherRepo = { const classTeacherRepo = {
find: jest.fn().mockResolvedValue([]), find: jest
.fn()
.mockResolvedValueOnce([{ id: 1, classId: 8, userId: 21 }])
.mockResolvedValueOnce([]),
delete: jest.fn().mockResolvedValue({ affected: 1 }), delete: jest.fn().mockResolvedValue({ affected: 1 }),
}; };
const service = new ClassesService( const service = new ClassesService(

View File

@@ -286,6 +286,7 @@ export class ClassesService {
async archive(id: number) { async archive(id: number) {
const cls = await this.classRepo.findOne({ where: { id } }); const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在'); if (!cls) throw new NotFoundException('班级不存在');
if (cls.isArchived) throw new BadRequestException('班级已归档');
await this.classRepo.update(id, { isArchived: true }); await this.classRepo.update(id, { isArchived: true });
return { success: true }; return { success: true };
} }
@@ -294,6 +295,7 @@ export class ClassesService {
async restore(id: number) { async restore(id: number) {
const cls = await this.classRepo.findOne({ where: { id } }); const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在'); if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('班级未归档');
await this.classRepo.update(id, { isArchived: false }); await this.classRepo.update(id, { isArchived: false });
return { success: true }; return { success: true };
} }
@@ -392,6 +394,9 @@ export class ClassesService {
} }
async addTeacher(classId: number, dto: AddTeacherDto) { async addTeacher(classId: number, dto: AddTeacherDto) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
const existing = await this.classTeacherRepo.findOne({ const existing = await this.classTeacherRepo.findOne({
where: { classId, userId: dto.userId, roleType: dto.roleType }, where: { classId, userId: dto.userId, roleType: dto.roleType },
}); });
@@ -410,12 +415,18 @@ export class ClassesService {
} }
async removeTeacher(classId: number, userId: number) { async removeTeacher(classId: number, userId: number) {
const assignments = await this.classTeacherRepo.find({ where: { classId, userId } });
if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级');
await this.classTeacherRepo.delete({ classId, userId }); await this.classTeacherRepo.delete({ classId, userId });
await this.syncClassTeacherIds(classId); await this.syncClassTeacherIds(classId);
return { success: true }; return { success: true };
} }
async removeTeacherAssignment(classId: number, assignmentId: number) { async removeTeacherAssignment(classId: number, assignmentId: number) {
const assignment = await this.classTeacherRepo.findOne({
where: { id: assignmentId, classId },
});
if (!assignment) throw new NotFoundException('教师角色分配不存在');
await this.classTeacherRepo.delete({ id: assignmentId, classId }); await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId); await this.syncClassTeacherIds(classId);
return { success: true }; return { success: true };

View File

@@ -1,43 +1,81 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator'; import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsArray,
IsDateString,
IsEnum,
ArrayNotEmpty,
ValidateNested,
Min,
} from 'class-validator';
import { Type, Transform } from 'class-transformer'; import { Type, Transform } from 'class-transformer';
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities'; import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
export class ClassTeacherItemDto {
@IsInt()
userId: number;
@IsEnum(TeacherRoleType)
roleType: string;
@IsOptional()
@IsString()
subject?: string;
}
export class CreateClassDto { export class CreateClassDto {
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
name: string; name: string;
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
code: string; code: string;
@IsEnum(ClassType)
@IsEnum(ClassType) @IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
classType: string; classType: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString() @IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string; status?: string;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
headTeacherId?: number; headTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
lifeTeacherId?: number; lifeTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
academicTeacherId?: number; academicTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
@Min(0)
maxStudents?: number; maxStudents?: number;
@IsOptional() @IsString() @IsOptional()
@IsString()
notes?: string; notes?: string;
@IsOptional() @IsArray() @IsOptional()
@IsArray()
@IsInt({ each: true })
studentIds?: number[]; studentIds?: number[];
@IsOptional() @IsOptional()
@@ -46,55 +84,73 @@ export class CreateClassDto {
@Type(() => ImportUserItem) @Type(() => ImportUserItem)
users?: ImportUserItem[]; users?: ImportUserItem[];
@IsOptional() @IsArray() @IsOptional()
teachers?: Array<{ userId: number; roleType: string; subject?: string }>; @IsArray()
@ValidateNested({ each: true })
@Type(() => ClassTeacherItemDto)
teachers?: ClassTeacherItemDto[];
} }
export class UpdateClassDto { export class UpdateClassDto {
@IsOptional() @IsString() @IsOptional()
@IsString()
name?: string; name?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
code?: string; code?: string;
@IsEnum(ClassType)
@IsEnum(ClassType) @IsOptional() @IsString() @IsOptional()
@IsString()
classType?: string; classType?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
@IsEnum(ClassStatus) @IsOptional() @IsString() @IsEnum(ClassStatus)
@IsOptional()
@IsString()
status?: string; status?: string;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
headTeacherId?: number; headTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
lifeTeacherId?: number; lifeTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
academicTeacherId?: number; academicTeacherId?: number;
@IsOptional() @IsInt() @IsOptional()
@IsInt()
@Min(0)
maxStudents?: number; maxStudents?: number;
@IsOptional() @IsString() @IsOptional()
@IsString()
notes?: string; notes?: string;
} }
export class QueryClassDto { export class QueryClassDto {
@IsOptional()
@IsOptional() @IsString() @IsString()
status?: string; status?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
classType?: string; classType?: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
keyword?: string; keyword?: string;
@IsOptional() @IsOptional()
@@ -108,7 +164,8 @@ export class QueryClassDto {
} }
export class AddStudentsDto { export class AddStudentsDto {
@IsArray() @IsInt({ each: true }) @IsArray()
@IsInt({ each: true })
studentIds: number[]; studentIds: number[];
} }
@@ -119,23 +176,28 @@ export class AddTeacherDto {
@IsEnum(TeacherRoleType) @IsEnum(TeacherRoleType)
roleType: string; roleType: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
subject?: string; subject?: string;
} }
export class QueryClassScheduleDto { export class QueryClassScheduleDto {
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
} }
export class QueryClassAttendanceSummaryDto { export class QueryClassAttendanceSummaryDto {
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
startDate?: string; startDate?: string;
@IsOptional() @IsDateString() @IsOptional()
@IsDateString()
endDate?: string; endDate?: string;
} }
export class BatchImportStudentsDto { export class BatchImportStudentsDto {
@@ -147,12 +209,15 @@ export class BatchImportStudentsDto {
} }
export class ImportUserItem { export class ImportUserItem {
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
dingUserId: string; dingUserId: string;
@IsString() @IsNotEmpty() @IsString()
@IsNotEmpty()
name: string; name: string;
@IsOptional() @IsString() @IsOptional()
@IsString()
mobile?: string; mobile?: string;
} }

View File

@@ -0,0 +1,42 @@
import { validate } from 'class-validator';
import { CreateRentalDto } from './rental.dto';
const createRental = (overrides: Partial<CreateRentalDto> = {}) =>
Object.assign(new CreateRentalDto(), {
classroomId: 1,
lesseeOrganizationId: 2,
startDate: '2026-08-01',
endDate: '2026-08-31',
...overrides,
});
describe('classroom rental DTO boundaries', () => {
it.each(['2026-02-31', '2026-08-01T00:00:00Z', '2026-8-1'])(
'rejects invalid or non-date-only value %s',
async (startDate) => {
const errors = await validate(createRental({ startDate }));
expect(errors.some((error) => error.property === 'startDate')).toBe(true);
},
);
it.each([
['dailyRate', 0],
['totalAmount', -1],
] as const)('rejects non-positive %s', async (field, value) => {
const errors = await validate(createRental({ [field]: value }));
expect(errors.some((error) => error.property === field)).toBe(true);
});
it('accepts positive amounts and a leap-day date', async () => {
await expect(
validate(
createRental({
startDate: '2028-02-29',
endDate: '2028-02-29',
dailyRate: 0.01,
totalAmount: 0.01,
}),
),
).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator'; import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator';
export class CreateRentalDto { export class CreateRentalDto {
@IsInt() @IsInt()
@@ -11,18 +11,22 @@ export class CreateRentalDto {
@IsInt() @IsInt()
lesseeOrganizationId: number; lesseeOrganizationId: number;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate: string; startDate: string;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate: string; endDate: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
dailyRate?: number; dailyRate?: number;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
totalAmount?: number; totalAmount?: number;
@IsOptional() @IsOptional()
@@ -44,19 +48,23 @@ export class UpdateRentalDto {
lesseeOrganizationId?: number; lesseeOrganizationId?: number;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string; startDate?: string;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string; endDate?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
dailyRate?: number; dailyRate?: number;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@Min(0.01)
totalAmount?: number; totalAmount?: number;
@IsOptional() @IsOptional()

View File

@@ -7,6 +7,7 @@ import {
SubjectName, SubjectName,
} from '../authorization'; } from '../authorization';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dto/dashboard-query.dto';
interface RequestUser { interface RequestUser {
id: number; id: number;
@@ -43,28 +44,18 @@ export class DashboardController {
} }
@Get('gantt') @Get('gantt')
getGanttData( getGanttData(@Query() query: DashboardGanttQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getGanttData(query);
@Query('periodEnd') periodEnd?: string,
@Query('building') building?: string,
) {
return this.service.getGanttData({ periodStart, periodEnd, building });
} }
@Get('expense-stats') @Get('expense-stats')
getExpenseStats( getExpenseStats(@Query() query: DashboardPeriodQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getExpenseStats(query.periodStart, query.periodEnd);
@Query('periodEnd') periodEnd?: string,
) {
return this.service.getExpenseStats(periodStart, periodEnd);
} }
@Get('room-ranking') @Get('room-ranking')
getRoomExpenseRanking( getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) {
@Query('periodStart') periodStart?: string, return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd);
@Query('periodEnd') periodEnd?: string,
) {
return this.service.getRoomExpenseRanking(periodStart, periodEnd);
} }
@Get('class-attendance-ranking') @Get('class-attendance-ranking')

View File

@@ -42,3 +42,45 @@ describe('DashboardService — teacher class scope', () => {
}); });
}); });
}); });
describe('DashboardService — boundary conditions', () => {
it('uses a deny-all predicate instead of an empty SQL IN list', async () => {
const qb = createQb();
const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new DashboardService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, attendanceRepo as never, {} as never, {} as never, {} as never,
{} as never, {} as never,
);
await (service as unknown as {
getAttendanceTrend: (today: string, classIds: number[]) => Promise<unknown>;
}).getAttendanceTrend('2026-07-14', []);
expect(qb.andWhere).toHaveBeenCalledWith('1 = 0');
});
it.each([
['getGanttData', [{ periodStart: '2026-08-01', periodEnd: '2026-07-31' }]],
['getExpenseStats', ['2026-08-01', '2026-07-31']],
['getRoomExpenseRanking', ['2026-08-01', '2026-07-31']],
] as const)('rejects a reversed period in %s', async (method, args) => {
const service = new DashboardService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
);
await expect((service[method] as (...values: never[]) => Promise<unknown>)(...(args as never[])))
.rejects.toThrow('结束日期不能早于开始日期');
});
it('uses the China calendar date when the server timezone is behind China', () => {
const service = new DashboardService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never,
);
expect((service as unknown as { getChinaDate: (date: Date) => string })
.getChinaDate(new Date('2026-07-13T16:30:00.000Z'))).toBe('2026-07-14');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm'; import { Repository, IsNull, Not, MoreThanOrEqual, In } from 'typeorm';
import { Room } from '../entities/room.entity'; import { Room } from '../entities/room.entity';
@@ -40,8 +40,7 @@ export class DashboardService {
} }
async getStats(accessibleClassIds?: number[]) { async getStats(accessibleClassIds?: number[]) {
const today = new Date(); const todayStr = this.getChinaDate(new Date());
const todayStr = today.toISOString().slice(0, 10);
const currentMonth = todayStr.slice(0, 7); // YYYY-MM const currentMonth = todayStr.slice(0, 7); // YYYY-MM
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } }); const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
@@ -180,6 +179,10 @@ export class DashboardService {
accessibleClassIds?: number[], accessibleClassIds?: number[],
) { ) {
if (accessibleClassIds) { if (accessibleClassIds) {
if (accessibleClassIds.length === 0) {
qb.andWhere('1 = 0');
return;
}
qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds }); qb.andWhere(`${alias}.classId IN (:...accessibleClassIds)`, { accessibleClassIds });
} }
} }
@@ -260,6 +263,7 @@ export class DashboardService {
// 甘特图数据:每个宿舍的入住时间线 // 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
this.assertPeriodRange(query?.periodStart, query?.periodEnd);
const qb = this.occRepo const qb = this.occRepo
.createQueryBuilder('o') .createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.student', 'student')
@@ -302,6 +306,7 @@ export class DashboardService {
} }
// 费用统计 // 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) { async getExpenseStats(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.select('e.expenseType', 'type') .select('e.expenseType', 'type')
@@ -314,6 +319,7 @@ export class DashboardService {
// 各宿舍费用排行 // 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
this.assertPeriodRange(periodStart, periodEnd);
const qb = this.expRepo const qb = this.expRepo
.createQueryBuilder('e') .createQueryBuilder('e')
.leftJoin('e.room', 'room') .leftJoin('e.room', 'room')
@@ -368,7 +374,7 @@ export class DashboardService {
where: { status: 'available' as const }, where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' }, order: { building: 'ASC', name: 'ASC' },
}); });
const today = new Date().toISOString().slice(0, 10); const today = this.getChinaDate(new Date());
const schedQb = this.scheduleRepo const schedQb = this.scheduleRepo
.createQueryBuilder('s') .createQueryBuilder('s')
.select('s.classroomId', 'classroomId') .select('s.classroomId', 'classroomId')
@@ -400,12 +406,31 @@ export class DashboardService {
})); }));
} }
private assertPeriodRange(periodStart?: string, periodEnd?: string) {
if (periodStart && periodEnd && periodStart > periodEnd) {
throw new BadRequestException('结束日期不能早于开始日期');
}
}
private getChinaDate(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const values = Object.fromEntries(
parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
);
return `${values.year}-${values.month}-${values.day}`;
}
async getClassroomUtilizationStats() { async getClassroomUtilizationStats() {
const totalClassrooms = await this.classroomRepo.count({ const totalClassrooms = await this.classroomRepo.count({
where: { status: 'available' as const }, where: { status: 'available' as const },
}); });
const today = new Date().toISOString().slice(0, 10); const today = this.getChinaDate(new Date());
// Count classrooms with active schedules today // Count classrooms with active schedules today
const schedQb = this.scheduleRepo const schedQb = this.scheduleRepo

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
describe('dashboard query boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only value %s',
async (periodStart) => {
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });
expect((await validate(dto)).some((error) => error.property === 'periodStart')).toBe(true);
},
);
it('accepts a valid date range and bounded building name', async () => {
const dto = plainToInstance(DashboardGanttQueryDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
building: 'A座',
});
expect(await validate(dto)).toEqual([]);
});
it('rejects an excessively long building name', async () => {
const dto = plainToInstance(DashboardGanttQueryDto, { building: 'A'.repeat(51) });
expect((await validate(dto)).some((error) => error.property === 'building')).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class DashboardPeriodQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodStart?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodEnd?: string;
}
export class DashboardGanttQueryDto extends DashboardPeriodQueryDto {
@IsOptional()
@IsString()
@MaxLength(50)
building?: string;
}

View File

@@ -0,0 +1,53 @@
import { BadRequestException } from '@nestjs/common';
import { DepositsService } from './deposits.service';
import { Deposit } from '../entities/deposit.entity';
function serviceWith(deposit?: Partial<Deposit>) {
const record = deposit ? ({ id: 1, studentId: 2, ...deposit } as Deposit) : null;
const repo = {
findOne: jest.fn(async (options: any) => options?.where?.studentId ? record : record),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const installmentRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 2 }) };
return { service: new DepositsService(repo as any, installmentRepo as any, studentRepo as any), repo, installmentRepo };
}
describe('DepositsService boundaries', () => {
it('rejects an installment amount that rounds to zero', async () => {
const { service, installmentRepo } = serviceWith({ amount: 500, status: 'paid' });
await expect(service.addInstallment(1, 0.004, '2026-08-01')).rejects.toBeInstanceOf(BadRequestException);
expect(installmentRepo.save).not.toHaveBeenCalled();
});
it('rejects a repeated full refund', async () => {
const { service, repo } = serviceWith({ amount: 0, status: 'refunded' });
await expect(service.refund(1, { refundDate: '2026-07-14' })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
it('rounds cumulative collections and clears stale refund audit fields', async () => {
const { service } = serviceWith({
amount: 10.01,
status: 'refunded',
refundDate: '2026-07-01',
refundAmount: 5,
refundedBy: 9,
refundedAt: new Date(),
});
const result = await service.create({ studentId: 2, amount: 0.02, paidDate: '2026-07-14' }, 7);
expect(result).toMatchObject({ amount: 10.03, status: 'paid', recordedBy: 7 });
expect(result.refundDate).toBeNull();
expect(result.refundAmount).toBeNull();
expect(result.refundedBy).toBeNull();
expect(result.refundedAt).toBeNull();
});
});

View File

@@ -7,6 +7,8 @@ import { DepositInstallment } from '../entities/deposit-installment.entity';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@Injectable() @Injectable()
export class DepositsService { export class DepositsService {
@@ -46,14 +48,22 @@ export class DepositsService {
async create(dto: CreateDepositDto, userId?: number) { async create(dto: CreateDepositDto, userId?: number) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0'); const amount = money(dto.amount);
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
throw new BadRequestException('收取金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('收取金额必须大于0');
const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
if (existing) { if (existing) {
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2)); existing.amount = money(Number(existing.amount || 0) + amount);
existing.paidDate = dto.paidDate; existing.paidDate = dto.paidDate;
existing.status = 'paid'; existing.status = 'paid';
existing.recordedBy = userId ?? null; existing.recordedBy = userId ?? null;
existing.refundDate = null as unknown as string;
existing.refundAmount = null as unknown as number;
existing.refundedBy = null;
existing.refundedAt = null;
if (dto.notes) existing.notes = dto.notes; if (dto.notes) existing.notes = dto.notes;
return this.repo.save(existing); return this.repo.save(existing);
} }
@@ -61,7 +71,7 @@ export class DepositsService {
return this.repo.save( return this.repo.save(
this.repo.create({ this.repo.create({
studentId: dto.studentId, studentId: dto.studentId,
amount: dto.amount, amount,
paidDate: dto.paidDate, paidDate: dto.paidDate,
notes: dto.notes, notes: dto.notes,
status: 'paid', status: 'paid',
@@ -71,12 +81,17 @@ export class DepositsService {
} }
async addInstallment(depositId: number, amount: number, dueDate: string) { async addInstallment(depositId: number, amount: number, dueDate: string) {
const normalizedAmount = money(amount);
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('分期金额最多保留两位小数');
}
if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0');
const deposit = await this.repo.findOne({ where: { id: depositId } }); const deposit = await this.repo.findOne({ where: { id: depositId } });
if (!deposit) throw new NotFoundException('押金记录不存在'); if (!deposit) throw new NotFoundException('押金记录不存在');
const installment = this.installmentRepo.create({ const installment = this.installmentRepo.create({
depositId, depositId,
amount, amount: normalizedAmount,
dueDate, dueDate,
status: 'pending', status: 'pending',
}); });
@@ -106,7 +121,7 @@ export class DepositsService {
throw new BadRequestException('该学生当前没有可退押金'); throw new BadRequestException('该学生当前没有可退押金');
} }
const refundAmount = Number(deposit.amount); const refundAmount = money(deposit.amount);
deposit.refundDate = dto.refundDate; deposit.refundDate = dto.refundDate;
deposit.refundAmount = refundAmount; deposit.refundAmount = refundAmount;

View File

@@ -1,10 +1,16 @@
import { IsString, IsOptional, IsInt, IsBoolean, IsIn } from 'class-validator'; import { IsString, IsOptional, IsInt, IsBoolean, IsIn, IsNotEmpty, Matches, MaxLength, Min } from 'class-validator';
export class CreateExpenseTypeDto { export class CreateExpenseTypeDto {
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/^[a-z][a-z0-9_]*$/)
code: string; code: string;
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/\S/)
name: string; name: string;
@IsOptional() @IsOptional()
@@ -13,12 +19,16 @@ export class CreateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0)
sortOrder?: number; sortOrder?: number;
} }
export class UpdateExpenseTypeDto { export class UpdateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsNotEmpty()
@MaxLength(30)
@Matches(/\S/)
name?: string; name?: string;
@IsOptional() @IsOptional()
@@ -27,6 +37,7 @@ export class UpdateExpenseTypeDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0)
sortOrder?: number; sortOrder?: number;
@IsOptional() @IsOptional()

View File

@@ -0,0 +1,68 @@
import 'reflect-metadata';
import { validate } from 'class-validator';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
import { ExpenseTypesService } from './expense-types.service';
describe('ExpenseTypesService boundaries', () => {
const createService = () => {
const repo = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ id: 1, ...value })),
remove: jest.fn(),
};
return { service: new ExpenseTypesService(repo as never), repo };
};
it('normalizes code and name before duplicate detection and save', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue(null);
await service.create({ code: ' water ', name: ' 水费 ' });
expect(repo.findOne).toHaveBeenCalledWith({ where: { code: 'water' } });
expect(repo.create).toHaveBeenCalledWith({ code: 'water', name: '水费' });
});
it('rejects a normalized duplicate code', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue({ id: 1, code: 'water' });
await expect(service.create({ code: ' water ', name: '水费' }))
.rejects.toBeInstanceOf(ConflictException);
});
it('trims an updated name and preserves omitted fields', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue({ id: 1, code: 'water', name: '旧名称', enabled: true });
await service.update(1, { name: ' 新名称 ' });
expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({
code: 'water', name: '新名称', enabled: true,
}));
});
it('rejects removal of a missing type', async () => {
const { service, repo } = createService();
repo.findOne.mockResolvedValue(null);
await expect(service.remove(999)).rejects.toBeInstanceOf(NotFoundException);
expect(repo.remove).not.toHaveBeenCalled();
});
});
describe('expense type DTO boundaries', () => {
it.each(['Water', '1water', 'water-fee', '', 'a'.repeat(31)])('rejects code %j', async (code) => {
const dto = Object.assign(new CreateExpenseTypeDto(), { code, name: '水费' });
expect((await validate(dto)).some((error) => error.property === 'code')).toBe(true);
});
it.each(['', ' '])('rejects blank name %j and negative sort order', async (name) => {
const dto = Object.assign(new CreateExpenseTypeDto(), {
code: 'water', name, sortOrder: -1,
});
const properties = (await validate(dto)).map((error) => error.property);
expect(properties).toEqual(expect.arrayContaining(['name', 'sortOrder']));
});
it('accepts zero sort order and boolean enabled update', async () => {
const dto = Object.assign(new UpdateExpenseTypeDto(), { sortOrder: 0, enabled: false });
expect(await validate(dto)).toEqual([]);
});
});

View File

@@ -52,14 +52,15 @@ export class ExpenseTypesService {
} }
async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> { async create(dto: CreateExpenseTypeDto): Promise<ExpenseType> {
const exists = await this.repo.findOne({ where: { code: dto.code } }); const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
const exists = await this.repo.findOne({ where: { code: normalized.code } });
if (exists) throw new ConflictException('费用类型代码已存在'); if (exists) throw new ConflictException('费用类型代码已存在');
return this.repo.save(this.repo.create(dto)); return this.repo.save(this.repo.create(normalized));
} }
async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> { async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
const t = await this.findOne(id); const t = await this.findOne(id);
Object.assign(t, dto); Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });
return this.repo.save(t); return this.repo.save(t);
} }

View File

@@ -0,0 +1,31 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { BatchRoomExpenseDto } from './expense.dto';
describe('BatchRoomExpenseDto boundaries', () => {
it.each([
{ expenses: [] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 0 }] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: -1 }] },
{ expenses: [{ roomId: 1, expenseType: 'water', amount: 1.001 }] },
{ periodStart: '2026-02-31' },
])('rejects invalid batch payload %#', async (override) => {
const dto = plainToInstance(BatchRoomExpenseDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [{ roomId: 1, expenseType: 'water', amount: 10 }],
...override,
});
await expect(validate(dto)).resolves.not.toHaveLength(0);
});
it('accepts a valid batch payload', async () => {
const dto = plainToInstance(BatchRoomExpenseDto, {
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [{ roomId: 1, expenseType: 'water', amount: 10.25 }],
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,4 @@
import { IsDateString, IsIn, IsInt, IsString, IsNumber, IsOptional, Matches, Min } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator';
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -13,9 +13,13 @@ export class CreateRoomExpenseDto {
@Min(0.01) @Min(0.01)
amount: number; amount: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString() @IsDateString()
periodStart: string; periodStart: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString() @IsDateString()
periodEnd: string; periodEnd: string;
@@ -39,6 +43,8 @@ export class CreatePersonalExpenseDto {
@Min(0.01) @Min(0.01)
amount: number; amount: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString() @IsDateString()
expenseDate: string; expenseDate: string;
@@ -74,14 +80,38 @@ export class QueryPersonalExpenseDto {
studentId?: number; studentId?: number;
} }
export class BatchRoomExpenseDto { export class BatchRoomExpenseItemDto {
@IsInt()
roomId: number;
@IsString() @IsString()
expenseType: string;
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
amount: number;
@IsOptional()
@IsString()
description?: string;
}
export class BatchRoomExpenseDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodStart: string; periodStart: string;
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsDateString()
periodEnd: string; periodEnd: string;
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[]; @IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => BatchRoomExpenseItemDto)
expenses: BatchRoomExpenseItemDto[];
} }

View File

@@ -0,0 +1,107 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { PersonalExpense } from '../entities/personal-expense.entity';
const qb = (affected = 1) => ({
delete: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected }),
});
function createService(options?: {
roomFind?: any[];
personalFind?: any[];
roomExpense?: any;
personalExpense?: any;
}) {
const roomExpRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn().mockResolvedValue(options?.roomFind ?? []),
findOne: jest.fn().mockResolvedValue(options?.roomExpense ?? null),
createQueryBuilder: jest.fn(() => qb()),
};
const personalExpRepo = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn().mockResolvedValue(options?.personalFind ?? []),
findOne: jest.fn().mockResolvedValue(options?.personalExpense ?? null),
createQueryBuilder: jest.fn(() => qb()),
delete: jest.fn(),
};
const roomRepo = {
find: jest.fn().mockImplementation(async () => options?.roomFind ?? []),
findOne: jest.fn().mockResolvedValue({ id: 1 }),
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) };
return {
service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any),
roomExpRepo,
personalExpRepo,
roomRepo,
};
}
describe('ExpensesService boundaries', () => {
it('rejects an empty room-expense batch', async () => {
const { service, roomExpRepo } = createService();
await expect(service.batchCreateRoomExpenses({
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [],
})).rejects.toBeInstanceOf(BadRequestException);
expect(roomExpRepo.save).not.toHaveBeenCalled();
});
it('rejects a batch when any room does not exist', async () => {
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
await expect(service.batchCreateRoomExpenses({
periodStart: '2026-07-01',
periodEnd: '2026-07-31',
expenses: [
{ roomId: 1, expenseType: 'water', amount: 10 },
{ roomId: 2, expenseType: 'water', amount: 20 },
],
})).rejects.toBeInstanceOf(NotFoundException);
expect(roomExpRepo.save).not.toHaveBeenCalled();
});
it('rejects a reversed room-expense period', async () => {
const { service } = createService();
await expect(service.createRoomExpense({
roomId: 1,
expenseType: 'water',
amount: 10,
periodStart: '2026-08-01',
periodEnd: '2026-07-31',
})).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a batch delete when only part of the ids exist', async () => {
const { service, roomExpRepo } = createService({ roomFind: [{ id: 1 }] });
await expect(service.batchDeleteRoomExpenses([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('does not edit or delete a personal expense already linked to a bill', async () => {
const linked = { id: 1, studentId: 1, amount: 20, billId: 9 } as PersonalExpense;
const { service, personalExpRepo } = createService({ personalExpense: linked });
await expect(service.updatePersonalExpense(1, { amount: 30 })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.deletePersonalExpense(1)).rejects.toBeInstanceOf(BadRequestException);
expect(personalExpRepo.save).not.toHaveBeenCalled();
expect(personalExpRepo.delete).not.toHaveBeenCalled();
});
it('rejects a personal-expense batch delete containing billed records', async () => {
const { service, personalExpRepo } = createService({
personalFind: [
{ id: 1, billId: null },
{ id: 2, billId: 9 },
],
});
await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException);
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
});

View File

@@ -42,6 +42,8 @@ export class ExpensesService {
// 宿舍费用 // 宿舍费用
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) { async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在'); if (!room) throw new NotFoundException('宿舍不存在');
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId }); const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
@@ -49,6 +51,12 @@ export class ExpensesService {
} }
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) { async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
if (!dto.expenses?.length) throw new BadRequestException('请至少填写一条费用');
dto.expenses.forEach((expense) => this.assertPositiveAmount(expense.amount));
const roomIds = [...new Set(dto.expenses.map((expense) => expense.roomId))];
const existingRooms = await this.roomRepo.find({ where: { id: In(roomIds) }, select: ['id'] });
if (existingRooms.length !== roomIds.length) throw new NotFoundException('部分宿舍不存在');
const entities = dto.expenses.map((e) => { const entities = dto.expenses.map((e) => {
const entity = this.roomExpRepo.create({ const entity = this.roomExpRepo.create({
roomId: e.roomId, roomId: e.roomId,
@@ -83,11 +91,14 @@ export class ExpensesService {
} }
async batchDeleteRoomExpenses(ids: number[]) { async batchDeleteRoomExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录'); const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo const result = await this.roomExpRepo
.createQueryBuilder() .createQueryBuilder()
.delete() .delete()
.where('id IN (:...ids)', { ids }) .where('id IN (:...ids)', { ids: uniqueIds })
.execute(); .execute();
return { message: '批量删除成功', deleted: result.affected || 0 }; return { message: '批量删除成功', deleted: result.affected || 0 };
} }
@@ -95,12 +106,40 @@ export class ExpensesService {
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) { async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } }); const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在'); if (!e) throw new NotFoundException('费用记录不存在');
const periodStart = dto.periodStart ?? e.periodStart;
const periodEnd = dto.periodEnd ?? e.periodEnd;
this.assertValidPeriod(periodStart, periodEnd);
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.roomId !== undefined && dto.roomId !== e.roomId) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
}
Object.assign(e, dto); Object.assign(e, dto);
return this.roomExpRepo.save(e); return this.roomExpRepo.save(e);
} }
private assertPositiveAmount(amount: number) {
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('费用金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
}
private assertValidPeriod(periodStart: string, periodEnd: string) {
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账期无效,结束日期不能早于开始日期');
}
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) { async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期'); this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const expense = await this.personalExpRepo.save( const expense = await this.personalExpRepo.save(
@@ -125,6 +164,7 @@ export class ExpensesService {
// 个人附加费 // 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) { async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId }); const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
@@ -144,16 +184,23 @@ export class ExpensesService {
async deletePersonalExpense(id: number) { async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } }); const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在'); if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能删除,请先取消账单');
await this.personalExpRepo.delete(id); await this.personalExpRepo.delete(id);
return { message: '删除成功' }; return { message: '删除成功' };
} }
async batchDeletePersonalExpenses(ids: number[]) { async batchDeletePersonalExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录'); const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo const result = await this.personalExpRepo
.createQueryBuilder() .createQueryBuilder()
.delete() .delete()
.where('id IN (:...ids)', { ids }) .where('id IN (:...ids)', { ids: uniqueIds })
.execute(); .execute();
return { message: '批量删除成功', deleted: result.affected || 0 }; return { message: '批量删除成功', deleted: result.affected || 0 };
} }
@@ -161,6 +208,12 @@ export class ExpensesService {
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) { async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } }); const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在'); if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
}
Object.assign(e, dto); Object.assign(e, dto);
return this.personalExpRepo.save(e); return this.personalExpRepo.save(e);
} }

View File

@@ -26,6 +26,7 @@ describe('IntegrationConfigService.testConnection', () => {
}), }),
}; };
global.fetch = jest.fn().mockResolvedValue({ global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ accessToken: 'token' }), json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
}) as never; }) as never;
@@ -47,3 +48,52 @@ describe('IntegrationConfigService.testConnection', () => {
); );
}); });
}); });
describe('IntegrationConfigService security boundaries', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('masks AppSecret without mutating the parsed source object', async () => {
const content = JSON.stringify({
config: { corpId: 'corp', agentId: 'agent', appSecret: 'top-secret' },
});
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]),
};
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(service.getThirdConfig()).resolves.toEqual([
{
type: 'DINGTALK',
verify: true,
config: { corpId: 'corp', agentId: 'agent' },
},
]);
expect(JSON.parse(content).config.appSecret).toBe('top-secret');
});
it('treats a non-2xx DingTalk token response as a failed connection even if it contains a token field', async () => {
const configRepo = { findOne: jest.fn() };
const detailRepo = { findOne: jest.fn() };
global.fetch = jest.fn().mockResolvedValue({
ok: false,
json: jest.fn().mockResolvedValue({ accessToken: 'must-not-be-used' }),
}) as never;
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(
service.testConnection('DINGTALK' as never, {
corpId: 'corp',
agentId: 'agent',
appSecret: 'secret',
}),
).resolves.toBe(false);
});
});

View File

@@ -205,13 +205,21 @@ export class IntegrationConfigService {
/** 调钉钉新版接口拿 access_token */ /** 调钉钉新版接口拿 access_token */
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> { private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', { const controller = new AbortController();
method: 'POST', const timeout = setTimeout(() => controller.abort(), 10_000);
headers: { 'Content-Type': 'application/json' }, try {
body: JSON.stringify({ appKey, appSecret }), const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
}); method: 'POST',
const body = (await res.json()) as { accessToken?: string; expireIn?: number }; headers: { 'Content-Type': 'application/json' },
return body.accessToken || null; body: JSON.stringify({ appKey, appSecret }),
signal: controller.signal,
});
if (!res.ok) return null;
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
return body.accessToken || null;
} finally {
clearTimeout(timeout);
}
} }
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */ /** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
@@ -219,9 +227,10 @@ export class IntegrationConfigService {
if (!content) return {}; if (!content) return {};
try { try {
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
const cfg = parsed.config || parsed; const source = parsed.config || parsed;
if (cfg.appSecret) delete cfg.appSecret; if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
return cfg; const { appSecret: _appSecret, ...masked } = source as Record<string, unknown>;
return masked;
} catch { } catch {
return {}; return {};
} }

View File

@@ -174,3 +174,41 @@ describe('DingTalkService — attendance machine only group', () => {
})); }));
}); });
}); });
describe('DingTalkService — department user pagination boundaries', () => {
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
it('stops when DingTalk says there is another page but omits the next cursor', async () => {
const service = new DingTalkService({} as never, {} as never);
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: {
list: [{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [1] }],
has_more: true,
},
}),
}) as jest.MockedFunction<typeof fetch>;
await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('stops when the next cursor repeats the current cursor', async () => {
const service = new DingTalkService({} as never, {} as never);
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
result: { list: [], has_more: true, next_cursor: 0 },
}),
}) as jest.MockedFunction<typeof fetch>;
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -279,8 +279,13 @@ export class DingTalkService {
if (body.errcode === 0 && body.result) { if (body.errcode === 0 && body.result) {
all.push(...body.result.list); all.push(...body.result.list);
hasMore = body.result.has_more; hasMore = body.result.has_more;
if (hasMore && body.result.next_cursor !== undefined) { if (hasMore) {
cursor = body.result.next_cursor; if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
hasMore = false;
} else {
cursor = body.result.next_cursor;
}
} }
} else { } else {
hasMore = false; hasMore = false;

View File

@@ -1,7 +1,7 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { NotificationQueryDto } from './notification.dto'; import { CreateNotificationDto, NotificationQueryDto } from './notification.dto';
describe('NotificationQueryDto', () => { describe('NotificationQueryDto', () => {
it('converts numeric query-string values before integer validation', async () => { it('converts numeric query-string values before integer validation', async () => {
@@ -15,3 +15,21 @@ describe('NotificationQueryDto', () => {
expect(dto.limit).toBe(20); expect(dto.limit).toBe(20);
}); });
}); });
describe('notification DTO boundaries', () => {
it('rejects an empty recipient set', async () => {
const dto = plainToInstance(CreateNotificationDto, {
recipientIds: [],
type: 'test',
title: '标题',
});
await expect(validate(dto)).resolves.toEqual(expect.arrayContaining([expect.any(Object)]));
});
it('rejects non-positive cursors and page sizes outside 1-100', async () => {
for (const value of [{ after: '0' }, { limit: '0' }, { limit: '101' }]) {
const dto = plainToInstance(NotificationQueryDto, value);
expect(await validate(dto)).not.toEqual([]);
}
});
});

View File

@@ -1,8 +1,18 @@
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator'; import {
ArrayNotEmpty,
IsString,
IsNotEmpty,
IsOptional,
IsArray,
IsInt,
Max,
Min,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class CreateNotificationDto { export class CreateNotificationDto {
@IsArray() @IsArray()
@ArrayNotEmpty()
@IsInt({ each: true }) @IsInt({ each: true })
recipientIds: number[]; recipientIds: number[];
@@ -27,10 +37,13 @@ export class NotificationQueryDto {
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1)
after?: number; after?: number;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1)
@Max(100)
limit?: number; limit?: number;
} }

View File

@@ -0,0 +1,61 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { NotificationsService } from './notifications.service';
function createQueryBuilder() {
return {
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
}
describe('NotificationsService boundaries', () => {
it('rejects empty recipients and de-duplicates repeated recipients', async () => {
const repo = {
save: jest.fn().mockImplementation(async (rows) => rows),
};
const service = new NotificationsService(repo as never, { emit: jest.fn() } as never);
await expect(
service.create({ recipientIds: [], type: 'test', title: '标题' }),
).rejects.toBeInstanceOf(BadRequestException);
const saved = await service.create({ recipientIds: [1, 1, 2], type: 'test', title: '标题' });
expect(saved).toHaveLength(2);
expect(repo.save).toHaveBeenCalledWith([
expect.objectContaining({ recipientId: 1 }),
expect.objectContaining({ recipientId: 2 }),
]);
});
it('clamps service-level page size to protect callers outside the controller', async () => {
const qb = createQueryBuilder();
const service = new NotificationsService(
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
new EventEmitter2(),
);
await service.findByUser(7, undefined, 1000);
expect(qb.take).toHaveBeenCalledWith(100);
});
it('does not allow marking another user notification as read', async () => {
const repo = { findOne: jest.fn().mockResolvedValue(null), update: jest.fn() };
const service = new NotificationsService(repo as never, new EventEmitter2());
await expect(service.markRead(3, 7)).rejects.toBeInstanceOf(NotFoundException);
expect(repo.update).not.toHaveBeenCalled();
});
it('treats marking an already-read notification as idempotent', async () => {
const repo = {
findOne: jest.fn().mockResolvedValue({ id: 3, recipientId: 7, isRead: true }),
update: jest.fn(),
};
const service = new NotificationsService(repo as never, new EventEmitter2());
await expect(service.markRead(3, 7)).resolves.toBeUndefined();
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Subject, Observable } from 'rxjs'; import { Subject, Observable } from 'rxjs';
@@ -18,7 +18,10 @@ export class NotificationsService {
) {} ) {}
async create(dto: CreateNotificationDto): Promise<Notification[]> { async create(dto: CreateNotificationDto): Promise<Notification[]> {
const notifications = dto.recipientIds.map((recipientId) => ({ const recipientIds = [...new Set(dto.recipientIds)];
if (recipientIds.length === 0) throw new BadRequestException('通知接收人不能为空');
const notifications = recipientIds.map((recipientId) => ({
recipientId, recipientId,
type: dto.type, type: dto.type,
title: dto.title, title: dto.title,
@@ -36,16 +39,13 @@ export class NotificationsService {
return saved; return saved;
} }
async findByUser( async findByUser(userId: number, after?: number, limit: number = 20): Promise<Notification[]> {
userId: number, const safeLimit = Math.min(Math.max(limit, 1), 100);
after?: number,
limit: number = 20,
): Promise<Notification[]> {
const qb = this.repo const qb = this.repo
.createQueryBuilder('n') .createQueryBuilder('n')
.where('n.recipientId = :userId', { userId }) .where('n.recipientId = :userId', { userId })
.orderBy('n.createdAt', 'DESC') .orderBy('n.createdAt', 'DESC')
.take(limit); .take(safeLimit);
if (after !== undefined) { if (after !== undefined) {
qb.andWhere('n.id < :after', { after }); qb.andWhere('n.id < :after', { after });
@@ -61,10 +61,10 @@ export class NotificationsService {
} }
async markRead(id: number, userId: number): Promise<void> { async markRead(id: number, userId: number): Promise<void> {
await this.repo.update( const notification = await this.repo.findOne({ where: { id, recipientId: userId } });
{ id, recipientId: userId }, if (!notification) throw new NotFoundException('通知不存在');
{ isRead: true, readAt: new Date() }, if (notification.isRead) return;
); await this.repo.update({ id, recipientId: userId }, { isRead: true, readAt: new Date() });
} }
async markAllRead(userId: number): Promise<void> { async markAllRead(userId: number): Promise<void> {

View File

@@ -71,3 +71,30 @@ describe('manual occupancy DTO bed requirements', () => {
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false); expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
}); });
}); });
describe('occupancy date boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only check-in date %s',
async (checkInDate) => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate,
bedId: 3,
});
expect((await validate(dto)).some((error) => error.property === 'checkInDate')).toBe(true);
},
);
it('accepts a leap-day date', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2028-02-29',
bedId: 3,
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,14 @@
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import {
IsArray,
IsBoolean,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Matches,
Min,
} from 'class-validator';
export class CheckInDto { export class CheckInDto {
@IsInt() @IsInt()
@@ -7,11 +17,13 @@ export class CheckInDto {
@IsInt() @IsInt()
roomId: number; roomId: number;
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkInDate: string; // YYYY-MM-DD checkInDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingStartDate?: string; // 默认=checkInDate可调整 billingStartDate?: string; // 默认=checkInDate可调整
@IsOptional() @IsOptional()
@@ -40,11 +52,13 @@ export class CheckInDto {
} }
export class CheckOutDto { export class CheckOutDto {
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string; checkOutDate: string;
@IsOptional() @IsOptional()
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate billingEndDate?: string; // 默认=checkOutDate
@IsOptional() @IsOptional()
@@ -56,11 +70,13 @@ export class TransferRoomDto {
@IsInt() @IsInt()
newRoomId: number; newRoomId: number;
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
transferDate: string; // YYYY-MM-DD transferDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsInt() @IsInt()
@@ -70,7 +86,8 @@ export class TransferRoomDto {
@IsInt() @IsInt()
newLockerId?: number; newLockerId?: number;
@IsOptional() @IsOptional()
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日 newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
@IsOptional() @IsOptional()
@@ -82,11 +99,13 @@ export class BatchCheckOutDto {
@IsArray() @IsArray()
ids: number[]; ids: number[];
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string; // YYYY-MM-DD checkOutDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate billingEndDate?: string; // 默认=checkOutDate
@IsOptional() @IsOptional()

View File

@@ -117,8 +117,9 @@ describe('OccupanciesService — manual check-in deposit', () => {
expect(depositRepo.save).toHaveBeenCalledTimes(1); expect(depositRepo.save).toHaveBeenCalledTimes(1);
}); });
it('does not create another paid deposit when one already exists', async () => { it('adds the collected amount to the existing student deposit', async () => {
const { service, depositRepo } = createService({ id: 99 } as Deposit); const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit;
const { service, depositRepo } = createService(existing);
await service.checkIn({ await service.checkIn({
studentId: 3, studentId: 3,
@@ -130,7 +131,15 @@ describe('OccupanciesService — manual check-in deposit', () => {
}); });
expect(depositRepo.create).not.toHaveBeenCalled(); expect(depositRepo.create).not.toHaveBeenCalled();
expect(depositRepo.save).not.toHaveBeenCalled(); expect(depositRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
id: 99,
amount: 1000,
status: 'paid',
paidDate: '2026-07-14',
notes: '入住登记自动收取',
}),
);
}); });
}); });
@@ -274,3 +283,66 @@ describe('OccupanciesService — import student matching', () => {
expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 })); expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 }));
}); });
}); });
describe('OccupanciesService — stay lifecycle boundaries', () => {
it('rejects check-out before check-in without releasing resources', async () => {
const occupancy = {
id: 1,
roomId: 2,
bedId: 3,
lockerId: 4,
checkInDate: '2026-07-10',
billingStartDate: '2026-07-10',
checkOutDate: null,
} as Occupancy;
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(occupancy),
save: jest.fn(),
} as any as Repository<Occupancy>;
const roomRepo = { update: jest.fn() } as any as Repository<Room>;
const bedRepo = { update: jest.fn() } as any as Repository<Bed>;
const lockerRepo = { update: jest.fn() } as any as Repository<Locker>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
{} as Repository<Student>,
{} as Repository<Deposit>,
bedRepo,
lockerRepo,
{} as Repository<any>,
{} as DataSource,
);
await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow(
'退宿日期不能早于入住日期',
);
expect(occupancyRepo.save).not.toHaveBeenCalled();
expect(bedRepo.update).not.toHaveBeenCalled();
expect(lockerRepo.update).not.toHaveBeenCalled();
expect(roomRepo.update).not.toHaveBeenCalled();
});
it('rejects check-in to a maintenance room', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn(),
} as any as Repository<Occupancy>;
const service = new OccupanciesService(
occupancyRepo,
{
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }),
} as any,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
);
await expect(
service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }),
).rejects.toThrow('该宿舍当前不可入住');
expect(occupancyRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -47,6 +47,8 @@ export class OccupanciesService {
} }
async checkIn(dto: CheckInDto, userId?: number) { async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
// 检查学生是否已有活跃入住 // 检查学生是否已有活跃入住
const existing = await this.repo.findOne({ const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() }, where: { studentId: dto.studentId, checkOutDate: IsNull() },
@@ -56,6 +58,9 @@ export class OccupanciesService {
// 检查宿舍容量 // 检查宿舍容量
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在'); if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived' || room.status === 'maintenance') {
throw new BadRequestException('该宿舍当前不可入住');
}
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } }); const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满'); if (count >= room.capacity) throw new BadRequestException('宿舍已满');
@@ -138,6 +143,12 @@ export class OccupanciesService {
const occ = await this.repo.findOne({ where: { id: occupancyId } }); const occ = await this.repo.findOne({ where: { id: occupancyId } });
if (!occ) throw new NotFoundException('入住记录不存在'); if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
occ.checkOutDate = dto.checkOutDate; occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
@@ -166,6 +177,14 @@ export class OccupanciesService {
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } }); const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
if (!oldOcc) throw new NotFoundException('入住记录不存在'); if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
this.assertDateOrder(
oldOcc.billingStartDate || oldOcc.checkInDate,
dto.oldBillingEndDate || dto.transferDate,
'原宿舍计费截止日不能早于计费起始日',
);
// 退旧房 // 退旧房
oldOcc.checkOutDate = dto.transferDate; oldOcc.checkOutDate = dto.transferDate;
@@ -183,6 +202,9 @@ export class OccupanciesService {
// 检查新房容量 // 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在'); if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
}
const count = await runner.manager.count(Occupancy, { const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
}); });
@@ -209,6 +231,11 @@ export class OccupanciesService {
const nextDay = new Date(transferDate); const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1); nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0]; const defaultBillingStart = nextDay.toISOString().split('T')[0];
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房 // 入住新房
const newOcc = runner.manager.create(Occupancy, { const newOcc = runner.manager.create(Occupancy, {
@@ -321,6 +348,17 @@ export class OccupanciesService {
errors.push(`${occ.student?.name || id}已退宿`); errors.push(`${occ.student?.name || id}已退宿`);
continue; continue;
} }
try {
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
} catch (error) {
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
continue;
}
occ.checkOutDate = dto.checkOutDate; occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || ''; occ.checkOutReason = dto.checkOutReason || '';
@@ -447,12 +485,25 @@ export class OccupanciesService {
); );
} }
// 3. 检查是否已有活跃入住 const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const checkOutDate = row.checkOutDate?.trim();
const billingStartDate = row.billingStartDate?.trim() || checkInDate;
const isHistoricalRecord = Boolean(checkOutDate);
this.assertDateOnly(checkInDate, '入住日期');
this.assertDateOnly(billingStartDate, '计费起始日');
this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期');
if (checkOutDate) {
this.assertDateOnly(checkOutDate, '退宿日期');
this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日');
}
// 3. 检查是否已有活跃入住(历史记录不影响当前入住)
const existing = await this.repo.findOne({ const existing = await this.repo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() }, where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'], relations: ['room'],
}); });
if (existing) { if (existing && !isHistoricalRecord) {
errors.push( errors.push(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, `${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
); );
@@ -462,7 +513,7 @@ export class OccupanciesService {
// 4. 检查宿舍容量 // 4. 检查宿舍容量
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (count >= room.capacity) { if (!isHistoricalRecord && count >= room.capacity) {
errors.push( errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, `${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
); );
@@ -471,7 +522,6 @@ export class OccupanciesService {
} }
// 5. 匹配或创建床位、柜子,并校验是否可用 // 5. 匹配或创建床位、柜子,并校验是否可用
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
let bed: Bed | null = null; let bed: Bed | null = null;
if (row.bedNumber?.trim()) { if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim(); const bedNumber = row.bedNumber.trim();
@@ -507,12 +557,11 @@ export class OccupanciesService {
} }
// 6. 创建入住记录 // 6. 创建入住记录
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const occData: any = { const occData: any = {
studentId: student.id, studentId: student.id,
roomId: room.id, roomId: room.id,
checkInDate, checkInDate,
billingStartDate: row.billingStartDate?.trim() || checkInDate, billingStartDate,
stayType: row.stayType || undefined, stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId, responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined, notes: row.notes || undefined,
@@ -520,9 +569,9 @@ export class OccupanciesService {
lockerId: locker?.id, lockerId: locker?.id,
}; };
// 如果有退宿日期,直接记录 // 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) { if (checkOutDate) {
occData.checkOutDate = row.checkOutDate.trim(); occData.checkOutDate = checkOutDate;
occData.billingEndDate = row.checkOutDate.trim(); occData.billingEndDate = checkOutDate;
} }
await this.repo.save(this.repo.create(occData)); await this.repo.save(this.repo.create(occData));
@@ -536,13 +585,15 @@ export class OccupanciesService {
} }
// 9. 自动收取押金(仅对新入住且非历史记录的学生) // 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) { if (options?.autoDeposit && !isHistoricalRecord) {
const existingDeposit = await this.depositRepo.findOne({ const existingDeposit = await this.depositRepo.findOne({
where: { studentId: student.id }, where: { studentId: student.id },
}); });
if (existingDeposit) { if (existingDeposit) {
existingDeposit.amount = Number( existingDeposit.amount = Number(
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2), (Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(
2,
),
); );
existingDeposit.status = 'paid'; existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate; existingDeposit.paidDate = checkInDate;
@@ -579,4 +630,26 @@ export class OccupanciesService {
errors: errors.length > 0 ? errors : undefined, errors: errors.length > 0 ? errors : undefined,
}; };
} }
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
this.assertDateOnly(start, '起始日期');
if (!end) return;
this.assertDateOnly(end, '结束日期');
if (end < start) throw new BadRequestException(message);
}
} }

View File

@@ -0,0 +1,74 @@
import { Type } from 'class-transformer';
import {
IsISO8601,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
export class QueryOperationLogsDto {
@IsOptional()
@IsString()
@MaxLength(50)
module?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
userId?: number;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 50;
}
export class CreateAuditLogDto {
@IsString()
@IsNotEmpty()
@MaxLength(50)
module: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
action: string;
@IsOptional()
@IsInt()
targetId?: number;
@IsOptional()
@IsString()
@MaxLength(50)
targetType?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
detail?: string;
}

View File

@@ -3,6 +3,7 @@ import { OperationLogsService } from './operation-logs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('operation-logs') @Controller('operation-logs')
@@ -11,29 +12,15 @@ export class OperationLogsController {
@Get() @Get()
@RequirePermission('log:view') @RequirePermission('log:view')
findAll( findAll(@Query() query: QueryOperationLogsDto) {
@Query('module') module?: string, return this.service.findAll(query);
@Query('userId') userId?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.findAll({
module,
userId: userId ? +userId : undefined,
startDate,
endDate,
page: page ? +page : 1,
pageSize: pageSize ? +pageSize : 50,
});
} }
@Post('audit') @Post('audit')
@RequirePermission('log:create') @RequirePermission('log:create')
async createAuditLog( async createAuditLog(
@Body() body: { module: string; action: string; targetId?: number; targetType?: string; detail?: string }, @Body() body: CreateAuditLogDto,
@Request() req: any, @Request() req: any,
) { ) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);

View File

@@ -0,0 +1,52 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { QueryOperationLogsDto } from './dto/operation-log.dto';
import { OperationLogsService } from './operation-logs.service';
const createQb = () => ({
orderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
});
describe('operation log query boundaries', () => {
it.each(['0', '-1', '1.5', 'abc'])('rejects invalid page %s', async (page) => {
const dto = plainToInstance(QueryOperationLogsDto, { page });
expect((await validate(dto)).some((error) => error.property === 'page')).toBe(true);
});
it.each(['0', '201', '1.5', 'abc'])('rejects invalid page size %s', async (pageSize) => {
const dto = plainToInstance(QueryOperationLogsDto, { pageSize });
expect((await validate(dto)).some((error) => error.property === 'pageSize')).toBe(true);
});
it.each(['2026-02-31', '2026-07-13T00:00:00Z'])(
'rejects invalid or non-date-only value %s',
async (startDate) => {
const dto = plainToInstance(QueryOperationLogsDto, { startDate });
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
},
);
it('transforms valid pagination and applies its database window', async () => {
const dto = plainToInstance(QueryOperationLogsDto, { page: '2', pageSize: '20' });
expect(await validate(dto)).toEqual([]);
const qb = createQb();
const service = new OperationLogsService({ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never);
await service.findAll(dto);
expect(qb.skip).toHaveBeenCalledWith(20);
expect(qb.take).toHaveBeenCalledWith(20);
});
it('rejects a reversed period before opening a query', async () => {
const repo = { createQueryBuilder: jest.fn() };
const service = new OperationLogsService(repo as never);
await expect(service.findAll({ startDate: '2026-08-01', endDate: '2026-07-31' }))
.rejects.toThrow('结束日期不能早于开始日期');
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { OperationLog } from '../entities/operation-log.entity'; import { OperationLog } from '../entities/operation-log.entity';
@@ -31,6 +31,9 @@ export class OperationLogsService {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}) { }) {
if (query?.startDate && query?.endDate && query.startDate > query.endDate) {
throw new BadRequestException('结束日期不能早于开始日期');
}
const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC'); const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC');
if (query?.module) qb.andWhere('log.module = :module', { module: query.module }); if (query?.module) qb.andWhere('log.module = :module', { module: query.module });
if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId }); if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });

View File

@@ -1,4 +1,13 @@
import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator'; import {
ArrayUnique,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
export class CreateRoleDto { export class CreateRoleDto {
@IsString() @IsString()
@@ -10,6 +19,9 @@ export class CreateRoleDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[]; permissionIds?: number[];
} }
@@ -24,6 +36,9 @@ export class UpdateRoleDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[]; permissionIds?: number[];
} }
@@ -40,6 +55,9 @@ export class CreateUserDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[]; roleIds?: number[];
} }
@@ -58,6 +76,9 @@ export class UpdateUserDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[]; roleIds?: number[];
} }
@@ -79,4 +100,3 @@ export class UpdateProfileDto {
@IsString() @IsString()
qualifications?: string; qualifications?: string;
} }

View File

@@ -0,0 +1,95 @@
import { ValidationPipe } from '@nestjs/common';
import { RbacService } from './rbac.service';
import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto';
function makeService(overrides?: {
permRepo?: Record<string, jest.Mock>;
roleRepo?: Record<string, jest.Mock>;
userRepo?: Record<string, jest.Mock>;
}) {
const permRepo = {
findByIds: jest.fn().mockResolvedValue([]),
...(overrides?.permRepo ?? {}),
};
const roleRepo = {
create: jest.fn((value) => ({ ...value })),
save: jest.fn(async (value) => value),
findByIds: jest.fn().mockResolvedValue([]),
findOneOrFail: jest.fn(),
...(overrides?.roleRepo ?? {}),
};
const userRepo = {
create: jest.fn((value) => ({ ...value })),
save: jest.fn(async (value) => value),
findOne: jest.fn().mockResolvedValue(null),
...(overrides?.userRepo ?? {}),
};
return {
service: new RbacService(
permRepo as never,
roleRepo as never,
userRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
),
permRepo,
roleRepo,
userRepo,
};
}
describe('RBAC mutation boundaries', () => {
it('rejects a role when any requested permission id does not exist', async () => {
const { service, roleRepo } = makeService({
permRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 1, code: 'student:view' }]) },
});
await expect(service.createRole({ name: 'partial', permissionIds: [1, 999] })).rejects.toThrow(
'权限不存在: 999',
);
expect(roleRepo.save).not.toHaveBeenCalled();
});
it('rejects a user when any requested role id does not exist', async () => {
const { service, userRepo } = makeService({
roleRepo: { findByIds: jest.fn().mockResolvedValue([{ id: 2, name: '老师' }]) },
});
await expect(
service.createUser({
username: 'alice',
password: 'secret',
name: 'Alice',
roleIds: [2, 404],
}),
).rejects.toThrow('角色不存在: 404');
expect(userRepo.save).not.toHaveBeenCalled();
});
it('allows explicitly clearing all roles from an existing user', async () => {
const user = { id: 7, username: 'alice', name: 'Alice', roles: [{ id: 2 }] };
const { service, userRepo } = makeService({
userRepo: { findOne: jest.fn().mockResolvedValue(user) },
});
await expect(service.updateUser(7, { roleIds: [] })).resolves.toEqual({ message: '更新成功' });
expect(user.roles).toEqual([]);
expect(userRepo.save).toHaveBeenCalledWith(user);
});
});
describe('RBAC DTO id arrays', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
it.each([
[CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }],
[CreateRoleDto, { name: 'role', permissionIds: [1, 1] }],
[CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }],
[UpdateUserDto, { roleIds: [1.5] }],
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
});

View File

@@ -301,7 +301,11 @@ export class RbacService {
} }
} }
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) { if (
role.code !== preset.code ||
role.name !== preset.name ||
role.description !== preset.description
) {
role.code = preset.code; role.code = preset.code;
role.name = preset.name; role.name = preset.name;
role.description = preset.description; role.description = preset.description;
@@ -368,6 +372,28 @@ export class RbacService {
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
} }
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
const uniqueIds = [...new Set(permissionIds)];
const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];
if (permissions.length !== uniqueIds.length) {
const foundIds = new Set(permissions.map((permission) => permission.id));
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
throw new Error(`权限不存在: ${missingIds.join(',')}`);
}
return permissions;
}
private async resolveRoles(roleIds: number[]): Promise<Role[]> {
const uniqueIds = [...new Set(roleIds)];
const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : [];
if (roles.length !== uniqueIds.length) {
const foundIds = new Set(roles.map((role) => role.id));
const missingIds = uniqueIds.filter((id) => !foundIds.has(id));
throw new Error(`角色不存在: ${missingIds.join(',')}`);
}
return roles;
}
async createRole(dto: { async createRole(dto: {
name: string; name: string;
description?: string; description?: string;
@@ -375,7 +401,7 @@ export class RbacService {
}): Promise<Role> { }): Promise<Role> {
const role = this.roleRepo.create({ name: dto.name, description: dto.description }); const role = this.roleRepo.create({ name: dto.name, description: dto.description });
if (dto.permissionIds && dto.permissionIds.length > 0) { if (dto.permissionIds && dto.permissionIds.length > 0) {
role.permissions = await this.permRepo.findByIds(dto.permissionIds); role.permissions = await this.resolvePermissions(dto.permissionIds);
} }
return this.roleRepo.save(role); return this.roleRepo.save(role);
} }
@@ -392,7 +418,7 @@ export class RbacService {
if (dto.description !== undefined) role.description = dto.description; if (dto.description !== undefined) role.description = dto.description;
if (dto.permissionIds !== undefined) { if (dto.permissionIds !== undefined) {
role.permissions = role.permissions =
dto.permissionIds.length > 0 ? await this.permRepo.findByIds(dto.permissionIds) : []; dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : [];
} }
return this.roleRepo.save(role); return this.roleRepo.save(role);
} }
@@ -472,7 +498,7 @@ export class RbacService {
name: dto.name, name: dto.name,
}); });
if (dto.roleIds && dto.roleIds.length > 0) { if (dto.roleIds && dto.roleIds.length > 0) {
user.roles = await this.roleRepo.findByIds(dto.roleIds); user.roles = await this.resolveRoles(dto.roleIds);
} }
await this.userRepo.save(user); await this.userRepo.save(user);
return { message: '用户创建成功' }; return { message: '用户创建成功' };
@@ -492,7 +518,7 @@ export class RbacService {
if (dto.name !== undefined) user.name = dto.name; if (dto.name !== undefined) user.name = dto.name;
if (dto.isActive !== undefined) user.isActive = dto.isActive; if (dto.isActive !== undefined) user.isActive = dto.isActive;
if (dto.roleIds !== undefined) { if (dto.roleIds !== undefined) {
user.roles = dto.roleIds.length > 0 ? await this.roleRepo.findByIds(dto.roleIds) : []; user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
} }
await this.userRepo.save(user); await this.userRepo.save(user);
return { message: '更新成功' }; return { message: '更新成功' };

View File

@@ -60,3 +60,30 @@ it('removes the retired departmentId field from create requests', async () => {
expect(dto).not.toHaveProperty('departmentId'); expect(dto).not.toHaveProperty('departmentId');
}); });
describe('schedule boundary validation', () => {
it.each(['24:00', '09:60', '99:99', '9:00'])('rejects invalid time %s', async (time) => {
const dto = createSchedule('');
dto.startTime = time;
expect((await validate(dto)).some((error) => error.property === 'startTime')).toBe(true);
});
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only value %s',
async (date) => {
const dto = createSchedule('');
dto.startDate = date;
expect((await validate(dto)).some((error) => error.property === 'startDate')).toBe(true);
},
);
it('accepts inclusive attendance-window limits and a leap-day date', async () => {
const zero = createSchedule('');
zero.attendanceAdvanceMinutes = 0;
zero.startDate = '2028-02-29';
const fullDay = createSchedule('');
fullDay.attendanceAdvanceMinutes = 1440;
expect(await validate(zero)).toEqual([]);
expect(await validate(fullDay)).toEqual([]);
});
});

View File

@@ -3,7 +3,8 @@ import {
IsString, IsString,
IsNotEmpty, IsNotEmpty,
IsInt, IsInt,
IsDateString, IsISO8601,
IsMilitaryTime,
Matches, Matches,
Min, Min,
Max, Max,
@@ -26,11 +27,11 @@ export class CreateScheduleDto {
@IsNotEmpty() @IsNotEmpty()
weekDay: number; weekDay: number;
@Matches(/^\d{2}:\d{2}$/) @IsMilitaryTime()
@IsNotEmpty() @IsNotEmpty()
startTime: string; startTime: string;
@Matches(/^\d{2}:\d{2}$/) @IsMilitaryTime()
@IsNotEmpty() @IsNotEmpty()
endTime: string; endTime: string;
@@ -40,11 +41,13 @@ export class CreateScheduleDto {
@Max(1440) @Max(1440)
attendanceAdvanceMinutes?: number; attendanceAdvanceMinutes?: number;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsNotEmpty() @IsNotEmpty()
startDate: string; startDate: string;
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
@IsNotEmpty() @IsNotEmpty()
endDate: string; endDate: string;
@@ -87,11 +90,11 @@ export class UpdateScheduleDto {
weekDay?: number; weekDay?: number;
@IsOptional() @IsOptional()
@Matches(/^\d{2}:\d{2}$/) @IsMilitaryTime()
startTime?: string; startTime?: string;
@IsOptional() @IsOptional()
@Matches(/^\d{2}:\d{2}$/) @IsMilitaryTime()
endTime?: string; endTime?: string;
@IsOptional() @IsOptional()
@@ -101,11 +104,13 @@ export class UpdateScheduleDto {
attendanceAdvanceMinutes?: number; attendanceAdvanceMinutes?: number;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string; startDate?: string;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string; endDate?: string;
@IsOptional() @IsOptional()
@@ -149,21 +154,25 @@ export class QueryScheduleDto {
weekDay?: number; weekDay?: number;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string; startDate?: string;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string; endDate?: string;
} }
export class WeeklyViewQueryDto { export class WeeklyViewQueryDto {
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
startDate?: string; startDate?: string;
@IsOptional() @IsOptional()
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
endDate?: string; endDate?: string;
@IsOptional() @IsOptional()

View File

@@ -339,3 +339,41 @@ describe('SchedulesService — remove', () => {
expect(scheduleRepo.remove).not.toHaveBeenCalled(); expect(scheduleRepo.remove).not.toHaveBeenCalled();
}); });
}); });
describe('SchedulesService — range boundaries', () => {
const makeService = () => {
const scheduleRepo = { create: jest.fn() };
return {
service: new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
),
scheduleRepo,
};
};
const valid = {
classId: 1, classroomId: 2, weekDay: 1,
startTime: '09:00', endTime: '10:00',
startDate: '2026-07-01', endDate: '2026-07-31', subject: '数学',
};
it('rejects zero-duration schedules before repository access', async () => {
const { service, scheduleRepo } = makeService();
await expect(service.create({ ...valid, endTime: '09:00' })).rejects.toThrow(
'上课时间和下课时间不能相同',
);
expect(scheduleRepo.create).not.toHaveBeenCalled();
});
it('rejects reversed date ranges before repository access', async () => {
const { service, scheduleRepo } = makeService();
await expect(
service.create({ ...valid, startDate: '2026-08-01', endDate: '2026-07-31' }),
).rejects.toThrow('排课结束日期不能早于开始日期');
expect(scheduleRepo.create).not.toHaveBeenCalled();
});
});

View File

@@ -179,7 +179,22 @@ export class SchedulesService {
} }
} }
private assertValidScheduleRange(
startTime: string,
endTime: string,
startDate: string,
endDate: string,
) {
if (startTime === endTime) {
throw new BadRequestException('上课时间和下课时间不能相同');
}
if (startDate > endDate) {
throw new BadRequestException('排课结束日期不能早于开始日期');
}
}
async create(dto: CreateScheduleDto) { async create(dto: CreateScheduleDto) {
this.assertValidScheduleRange(dto.startTime, dto.endTime, dto.startDate, dto.endDate);
await this.assertClassroomAvailable(dto.classroomId); await this.assertClassroomAvailable(dto.classroomId);
await this.normalizeTeacherForSchedule(dto); await this.normalizeTeacherForSchedule(dto);
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId); await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
@@ -211,6 +226,7 @@ export class SchedulesService {
const endTime = dto.endTime ?? existing.endTime; const endTime = dto.endTime ?? existing.endTime;
const startDate = dto.startDate ?? existing.startDate; const startDate = dto.startDate ?? existing.startDate;
const endDate = dto.endDate ?? existing.endDate; const endDate = dto.endDate ?? existing.endDate;
this.assertValidScheduleRange(startTime, endTime, startDate, endDate);
const normalized = await this.normalizeTeacherForSchedule({ const normalized = await this.normalizeTeacherForSchedule({
...dto, ...dto,

View File

@@ -35,6 +35,122 @@ interface AuthenticatedRequest {
user: AuthenticatedUser; user: AuthenticatedUser;
} }
interface StudentImportRow {
name: string;
studentNo?: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
organizationId?: number;
}
const STUDENT_IMPORT_COLUMNS = [
{ header: '姓名', key: 'name', width: 15 },
{ header: '学号', key: 'studentNo', width: 15 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '电话', key: 'phone', width: 18 },
{ header: '身份证号', key: 'idNumber', width: 22 },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构名称', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
const STUDENT_EXPORT_COLUMNS = [
...STUDENT_IMPORT_COLUMNS.map((column) => ({
...column,
header: column.key === 'organization' ? '所属机构' : column.header,
})),
{ header: '状态', key: 'status', width: 10 },
];
const STUDENT_IMPORT_HEADER_MAP: Record<string, keyof StudentImportRow> = {
: 'name',
: 'studentNo',
: 'phone',
: 'phone',
'学号/身份证': 'idNumber',
: 'idNumber',
: 'idNumber',
: 'gender',
: 'ethnicity',
: 'emergencyContact',
: 'emergencyPhone',
: 'organization',
: 'organization',
: 'supervisor',
'负责人/班主任': 'supervisor',
};
function getExcelCellText(cell: ExcelJS.Cell): string {
const value = cell.value;
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
if ('text' in value) return String(value.text || '');
if ('richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('');
}
if ('result' in value) return String(value.result || '');
}
return String(value);
}
function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] {
const headerIndex = new Map<number, keyof StudentImportRow>();
ws.getRow(1).eachCell((cell, colNumber) => {
const header = getExcelCellText(cell).trim();
const field = STUDENT_IMPORT_HEADER_MAP[header];
if (field) headerIndex.set(colNumber, field);
});
const rows: StudentImportRow[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
const parsed: Partial<StudentImportRow> = {};
if (headerIndex.size > 0) {
headerIndex.forEach((field, colNumber) => {
const value = getExcelCellText(row.getCell(colNumber)).trim();
if (value) {
Object.assign(parsed, { [field]: value });
}
});
} else {
parsed.name = getExcelCellText(row.getCell(1)).trim();
parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined;
parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined;
parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined;
parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined;
parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined;
parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined;
parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined;
parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined;
parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined;
}
rows.push({
name: parsed.name || '',
studentNo: parsed.studentNo,
phone: parsed.phone,
idNumber: parsed.idNumber,
gender: parsed.gender,
ethnicity: parsed.ethnicity,
emergencyContact: parsed.emergencyContact,
emergencyPhone: parsed.emergencyPhone,
organization: parsed.organization,
supervisor: parsed.supervisor,
});
});
return rows;
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('students') @Controller('students')
export class StudentsController { export class StudentsController {
@@ -92,18 +208,7 @@ export class StudentsController {
); );
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('学生名单'); const ws = workbook.addWorksheet('学生名单');
ws.columns = [ ws.columns = STUDENT_EXPORT_COLUMNS;
{ header: '姓名', key: 'name', width: 12 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '电话', key: 'phone', width: 18 },
{ header: '学号/身份证', key: 'idNumber', width: 22 },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '状态', key: 'status', width: 10 },
];
ws.getRow(1).font = { bold: true }; ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
@@ -115,6 +220,7 @@ export class StudentsController {
for (const s of students) { for (const s of students) {
ws.addRow({ ws.addRow({
name: s.name, name: s.name,
studentNo: s.studentNo || '',
gender: s.gender || '', gender: s.gender || '',
phone: s.phone || '', phone: s.phone || '',
idNumber: s.idNumber || '', idNumber: s.idNumber || '',
@@ -150,24 +256,15 @@ export class StudentsController {
async downloadTemplate(@Res() res: Response) { async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('学生导入模板'); const ws = workbook.addWorksheet('学生导入模板');
ws.columns = [ ws.columns = STUDENT_IMPORT_COLUMNS;
{ header: '姓名', key: 'name', width: 15 },
{ header: '电话', key: 'phone', width: 18 },
{ header: '学号/身份证', key: 'idNumber', width: 22 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构名称', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
ws.getRow(1).font = { bold: true }; ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ ws.addRow({
name: '张三', name: '张三',
phone: '13800138000', studentNo: '2024001',
idNumber: '2024001',
gender: '男', gender: '男',
phone: '13800138000',
idNumber: '11010120060101001X',
ethnicity: '汉族', ethnicity: '汉族',
emergencyContact: '张父', emergencyContact: '张父',
emergencyPhone: '13900000000', emergencyPhone: '13900000000',
@@ -288,32 +385,7 @@ export class StudentsController {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any); await workbook.xlsx.load(file.buffer as any);
const ws = workbook.worksheets[0]; const ws = workbook.worksheets[0];
const rows: { const rows = parseStudentImportRows(ws);
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
name: String(row.getCell(1).value || ''),
phone: String(row.getCell(2).value || ''),
idNumber: String(row.getCell(3).value || ''),
gender: String(row.getCell(4).value || '').trim() || undefined,
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve organization names to IDs // Resolve organization names to IDs
for (const row of rows) { for (const row of rows) {
if (row.organization) { if (row.organization) {
@@ -346,32 +418,7 @@ export class StudentsController {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
const ws = workbook.worksheets[0]; const ws = workbook.worksheets[0];
const rows: { const rows = parseStudentImportRows(ws);
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
name: String(row.getCell(1).value || ''),
phone: String(row.getCell(2).value || ''),
idNumber: String(row.getCell(3).value || ''),
gender: String(row.getCell(4).value || '').trim() || undefined,
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve organization names to IDs // Resolve organization names to IDs
for (const row of rows) { for (const row of rows) {
if (row.organization) { if (row.organization) {
@@ -386,7 +433,7 @@ export class StudentsController {
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '学生管理', module: '学生管理',
action: '匹配导入学生', action: '更新已有学生资料',
detail: result.message, detail: result.message,
ipAddress, ipAddress,
userAgent, userAgent,

View File

@@ -0,0 +1,43 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { StudentsService } from './students.service';
function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
return new StudentsService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
organizationRepo as never,
);
}
describe('StudentsService — archive lifecycle boundaries', () => {
it('rejects archiving an already archived student', async () => {
const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'archived' }) };
await expect(createService(repo).remove(1)).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects restoring a student that is not archived', async () => {
const repo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'active' }) };
await expect(createService(repo).restore(1)).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an empty batch archive', async () => {
await expect(createService({}).batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects creating a student under a missing or archived organization', async () => {
const repo = { create: jest.fn(), save: jest.fn() };
const organizationRepo = { findOne: jest.fn().mockResolvedValue(null) };
await expect(
createService(repo, organizationRepo).create({ name: '张三', organizationId: 9 }),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.save).not.toHaveBeenCalled();
});
it('returns not found for a missing student', async () => {
const repo = { findOne: jest.fn().mockResolvedValue(null) };
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -132,6 +132,7 @@ export class StudentsService {
async batchImport( async batchImport(
rows: { rows: {
name: string; name: string;
studentNo?: string;
phone?: string; phone?: string;
idNumber?: string; idNumber?: string;
gender?: string; gender?: string;
@@ -158,6 +159,7 @@ export class StudentsService {
await this.repo.save( await this.repo.save(
this.repo.create({ this.repo.create({
name: row.name.trim(), name: row.name.trim(),
studentNo: row.studentNo?.trim() || undefined,
phone: row.phone?.trim() || undefined, phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined, idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined, gender: row.gender || undefined,
@@ -180,6 +182,7 @@ export class StudentsService {
async matchImport( async matchImport(
rows: { rows: {
name: string; name: string;
studentNo?: string;
phone?: string; phone?: string;
idNumber?: string; idNumber?: string;
gender?: string; gender?: string;
@@ -194,10 +197,6 @@ export class StudentsService {
let matched = 0; let matched = 0;
let skipped = 0; let skipped = 0;
for (const row of rows) { for (const row of rows) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
// Match by phone first, then idNumber // Match by phone first, then idNumber
let student = row.phone?.trim() let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } }) ? await this.repo.findOne({ where: { phone: row.phone.trim() } })
@@ -214,6 +213,7 @@ export class StudentsService {
Pick< Pick<
Student, Student,
| 'name' | 'name'
| 'studentNo'
| 'phone' | 'phone'
| 'idNumber' | 'idNumber'
| 'gender' | 'gender'
@@ -225,6 +225,7 @@ export class StudentsService {
> >
> = {}; > = {};
if (row.name?.trim()) updates.name = row.name.trim(); if (row.name?.trim()) updates.name = row.name.trim();
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
if (row.phone?.trim()) updates.phone = row.phone.trim(); if (row.phone?.trim()) updates.phone = row.phone.trim();
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender; if (row.gender) updates.gender = row.gender;
@@ -237,7 +238,7 @@ export class StudentsService {
matched++; matched++;
} }
return { return {
message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`, message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
matched, matched,
skipped, skipped,
}; };

View File

@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ScheduleSyncQueryDto } from './schedule-sync.dto';
describe('ScheduleSyncQueryDto', () => {
it('transforms valid query strings', async () => {
const dto = plainToInstance(ScheduleSyncQueryDto, {
dateFrom: '2026-07-13',
days: '7',
attendanceMachineOnly: 'true',
});
expect(await validate(dto)).toEqual([]);
expect(dto).toMatchObject({ days: 7, attendanceMachineOnly: true });
});
it.each(['0', '-1', '91', '7.5', 'abc'])('rejects invalid sync days %s', async (days) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { days });
expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true);
});
it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])(
'rejects invalid or non-date-only start date %s',
async (dateFrom) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom });
expect((await validate(dto)).some((error) => error.property === 'dateFrom')).toBe(true);
},
);
it('treats non-true boolean strings as false', async () => {
const dto = plainToInstance(ScheduleSyncQueryDto, { attendanceMachineOnly: 'false' });
expect(await validate(dto)).toEqual([]);
expect(dto.attendanceMachineOnly).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator';
export class ScheduleSyncQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
dateFrom?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(90)
days: number = 30;
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
attendanceMachineOnly: boolean = false;
}

View File

@@ -549,3 +549,63 @@ describe('ScheduleSyncService — multiple lessons per student per day', () => {
}); });
}); });
describe('ScheduleSyncService — date and overnight boundaries', () => {
const createService = (schedule: ClassSchedule) => {
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const upsertShift = jest.fn().mockResolvedValue(501);
const service = new ScheduleSyncService(
{ find: jest.fn().mockResolvedValue([schedule]) } as never,
{ find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]) } as never,
{ find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]) } as never,
{ find: jest.fn().mockResolvedValue([{ id: 10, name: '边界班' }]) } as never,
{
queryShifts: jest.fn().mockResolvedValue([]), upsertShift,
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 88, group_name: '排课_边界班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(), scheduleUsers,
} as never,
);
return { service, scheduleUsers, upsertShift };
};
it('syncs exactly the requested number of calendar days', async () => {
const { service, scheduleUsers } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-20', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 7);
expect(scheduleUsers.mock.calls.flatMap((call) => call[1])).toHaveLength(1);
});
it('marks an overnight lesson off-duty time as next-day', async () => {
const { service, upsertShift } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '22:00', endTime: '01:00',
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 1);
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
sections: [expect.objectContaining({ times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', across: 0 }),
expect.objectContaining({ check_type: 'OffDuty', across: 1 }),
]) })],
}));
});
it('does not shift the requested date when the server timezone is behind China', async () => {
const originalTz = process.env.TZ;
process.env.TZ = 'America/Los_Angeles';
try {
const { service, scheduleUsers } = createService({
id: 1, classId: 10, weekDay: 1, startTime: '09:00', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13', status: 'active',
} as ClassSchedule);
await service.syncAll('2026-07-13', 1);
expect(scheduleUsers).toHaveBeenCalledTimes(1);
} finally {
process.env.TZ = originalTz;
}
});
});

View File

@@ -94,7 +94,8 @@ export class ScheduleSyncService {
attendanceMachineOnly = false, attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> { ): Promise<ScheduleSyncResult> {
const startDate = dateFrom || new Date().toISOString().slice(0, 10); const startDate = dateFrom || new Date().toISOString().slice(0, 10);
const endDate = this.addDays(startDate, days); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
const endDate = this.addDays(startDate, normalizedDays - 1);
const empty: ScheduleSyncResult = { const empty: ScheduleSyncResult = {
scheduleCount: 0, scheduleCount: 0,
@@ -170,7 +171,7 @@ export class ScheduleSyncService {
}, },
{ {
check_type: 'OffDuty' as const, check_type: 'OffDuty' as const,
across: 0, across: this.toMinutes(period.endTime) <= this.toMinutes(period.startTime) ? 1 : 0,
check_time: `1970-01-01 ${period.endTime}:00`, check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false, free_check: false,
}, },
@@ -377,12 +378,12 @@ export class ScheduleSyncService {
syncTo: string, syncTo: string,
): DailySchedulePlan[] { ): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>(); const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const fromDate = new Date(syncFrom); const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
const toDate = new Date(syncTo); const toDate = new Date(`${syncTo}T00:00:00.000Z`);
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) { for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10); const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getDay() === 0 ? 7 : date.getDay(); const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
for (const schedule of schedules) { for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue; if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
@@ -450,18 +451,21 @@ export class ScheduleSyncService {
return items; return items;
} }
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private minutesBetween(startTime: string, endTime: string): number { private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number); const start = this.toMinutes(startTime);
const [endHour, endMinute] = endTime.split(':').map(Number); let end = this.toMinutes(endTime);
const start = startHour * 60 + startMinute;
let end = endHour * 60 + endMinute;
if (end <= start) end += 24 * 60; if (end <= start) end += 24 * 60;
return end - start; return end - start;
} }
private addDays(dateStr: string, days: number): string { private addDays(dateStr: string, days: number): string {
const d = new Date(dateStr); const d = new Date(`${dateStr}T00:00:00.000Z`);
d.setDate(d.getDate() + days); d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }

View File

@@ -1,4 +1,5 @@
import { SyncController } from './sync.controller'; import { SyncController } from './sync.controller';
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
describe('SyncController — schedule sync options', () => { describe('SyncController — schedule sync options', () => {
it('forwards the attendance-machine-only option', async () => { it('forwards the attendance-machine-only option', async () => {
@@ -7,11 +8,11 @@ describe('SyncController — schedule sync options', () => {
}; };
const controller = new SyncController(syncService as never); const controller = new SyncController(syncService as never);
await (controller.syncSchedule as unknown as ( await controller.syncSchedule(Object.assign(new ScheduleSyncQueryDto(), {
dateFrom?: string, dateFrom: '2026-07-10',
days?: string, days: 30,
attendanceMachineOnly?: string, attendanceMachineOnly: true,
) => Promise<unknown>)('2026-07-10', '30', 'true'); }));
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith( expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
'2026-07-10', '2026-07-10',

View File

@@ -3,6 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { SyncService } from './sync.service'; import { SyncService } from './sync.service';
import type { SyncPlatform } from '../entities/sync-log.entity'; import type { SyncPlatform } from '../entities/sync-log.entity';
import { ScheduleSyncQueryDto } from './dto/schedule-sync.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('sync') @Controller('sync')
@@ -79,15 +80,11 @@ export class SyncController {
/** 触发排班同步到钉钉考勤排班 */ /** 触发排班同步到钉钉考勤排班 */
@Post('schedule/sync') @Post('schedule/sync')
@RequirePermission('sync:trigger') @RequirePermission('sync:trigger')
async syncSchedule( async syncSchedule(@Query() query: ScheduleSyncQueryDto) {
@Query('dateFrom') dateFrom?: string,
@Query('days') days?: string,
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
) {
const result = await this.syncService.syncScheduleToDingTalk( const result = await this.syncService.syncScheduleToDingTalk(
dateFrom, query.dateFrom,
days ? parseInt(days, 10) : 30, query.days,
attendanceMachineOnly === 'true', query.attendanceMachineOnly,
); );
return { success: true, data: result }; return { success: true, data: result };
} }

View File

@@ -50,3 +50,70 @@ describe('WalletsService payment rules', () => {
expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true); expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true);
}); });
}); });
describe('WalletsService financial boundaries', () => {
it('caps a debit at total minus already paid even when outstandingAmount is stale', async () => {
const ctx = manager(50);
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
const bill = {
id: 10,
studentId: 10,
totalAmount: 100,
paidAmount: 90,
outstandingAmount: 100,
status: 'partially_paid',
} as Bill;
await service.debitBill(ctx.value as any, bill);
expect(bill).toMatchObject({ paidAmount: 100, outstandingAmount: 0, status: 'paid' });
expect(ctx.wallet.balance).toBe(40);
expect(ctx.saved.some((row) => row.type === 'bill_payment' && row.amount === -10)).toBe(true);
});
it('caps a refund at the bill total when paidAmount is corrupt', async () => {
const ctx = manager(10);
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
const bill = {
id: 11,
studentId: 10,
totalAmount: 100,
paidAmount: 150,
outstandingAmount: 0,
status: 'paid',
} as Bill;
await service.refundBill(ctx.value as any, bill, '冲正');
expect(ctx.wallet.balance).toBe(110);
expect(ctx.saved.some((row) => row.type === 'bill_refund' && row.amount === 100)).toBe(true);
});
it('does not issue a second refund for an already cancelled bill', async () => {
const ctx = manager(10);
const service = new WalletsService({} as any, {} as any, {} as any, {} as any);
const bill = {
id: 12,
studentId: 10,
totalAmount: 100,
paidAmount: 100,
outstandingAmount: 0,
status: 'cancelled',
} as Bill;
await service.refundBill(ctx.value as any, bill, '重复取消');
expect(ctx.wallet.balance).toBe(10);
expect(ctx.saved).toHaveLength(0);
});
it('rejects an amount that rounds to zero before opening a transaction', async () => {
const dataSource = { transaction: jest.fn() };
const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any);
await expect(service.changeBalance({ studentId: 1, amount: 0.004, type: 'adjustment' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});

View File

@@ -61,12 +61,17 @@ export class WalletsService {
} }
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0'); const amount = money(dto.amount);
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
throw new BadRequestException('调账金额最多保留两位小数');
}
if (amount === 0) throw new BadRequestException('调账金额不能为 0');
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const wallet = await this.getOrCreateWallet(manager, dto.studentId); const wallet = await this.getOrCreateWallet(manager, dto.studentId);
const nextBalance = money(Number(wallet.balance) + dto.amount); const nextBalance = money(Number(wallet.balance) + amount);
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0'); if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
wallet.balance = nextBalance; wallet.balance = nextBalance;
await manager.save(wallet); await manager.save(wallet);
@@ -75,29 +80,43 @@ export class WalletsService {
studentId: dto.studentId, studentId: dto.studentId,
billId: null, billId: null,
type: dto.type, type: dto.type,
amount: money(dto.amount), amount,
balanceAfter: nextBalance, balanceAfter: nextBalance,
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'), description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
recordedBy: recordedBy || null, recordedBy: recordedBy || null,
}), }),
); );
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
return { wallet: finalWallet, payments }; return { wallet: finalWallet, payments };
}); });
} }
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) { async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill; if (bill.status === 'cancelled') return bill;
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount))); const total = money(bill.totalAmount);
if (amount <= 0) { const paid = Math.max(0, Math.min(money(bill.paidAmount), total));
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid'; const remaining = money(Math.max(0, total - paid));
if (remaining <= 0) {
bill.paidAmount = total;
bill.outstandingAmount = 0;
bill.status = 'paid';
return manager.save(bill); return manager.save(bill);
} }
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));
if (amount <= 0) {
bill.paidAmount = paid;
bill.outstandingAmount = remaining;
bill.status = paid > 0 ? 'partially_paid' : 'unpaid';
return manager.save(bill);
}
wallet.balance = money(Number(wallet.balance) - amount); wallet.balance = money(Number(wallet.balance) - amount);
bill.paidAmount = money(Number(bill.paidAmount) + amount); bill.paidAmount = money(paid + amount);
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount)); bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid'; bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
await manager.save(wallet); await manager.save(wallet);
await manager.save(bill); await manager.save(bill);
@@ -109,14 +128,16 @@ export class WalletsService {
amount: -amount, amount: -amount,
balanceAfter: wallet.balance, balanceAfter: wallet.balance,
description: `账单 #${bill.id} 自动扣款`, description: `账单 #${bill.id} 自动扣款`,
recordedBy: recordedBy || null, recordedBy: recordedBy ?? null,
}), }),
); );
return bill; return bill;
} }
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) { async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
const paid = money(bill.paidAmount); if (bill.status === 'cancelled') return bill;
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
if (paid > 0) { if (paid > 0) {
const wallet = await this.getOrCreateWallet(manager, bill.studentId); const wallet = await this.getOrCreateWallet(manager, bill.studentId);
wallet.balance = money(Number(wallet.balance) + paid); wallet.balance = money(Number(wallet.balance) + paid);
@@ -129,7 +150,7 @@ export class WalletsService {
amount: paid, amount: paid,
balanceAfter: wallet.balance, balanceAfter: wallet.balance,
description: `取消账单 #${bill.id} 冲正:${reason}`, description: `取消账单 #${bill.id} 冲正:${reason}`,
recordedBy: recordedBy || null, recordedBy: recordedBy ?? null,
}), }),
); );
} }