Merge pull request '添加考勤设备 SN 教室绑定' (#16)

This commit is contained in:
2026-07-16 02:49:30 +00:00
25 changed files with 1464 additions and 61 deletions

View File

@@ -8,11 +8,15 @@ MYSQL_ROOT_PASSWORD=change-me-to-a-strong-password
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_DATABASE=gongxue
DB_DATABASE=dorm_billing_v2
DB_SYNCHRONIZE=false
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
JWT_EXPIRES_IN=24h
PORT=3000
# 初始管理员 admin 密码(仅首次创建 admin 用户时生效)
ADMIN_PASSWORD=change-me-admin-password
PORT=3002
# ---- AI 模型配置 ----
# AES-256-GCM 加密主密钥,用于加密存储 API Key

View File

@@ -31,6 +31,7 @@ const SchedulesPage = lazy(() => import('./pages/Schedules'));
const RolesPage = lazy(() => import('./pages/Roles'));
const PermissionsPage = lazy(() => import('./pages/Permissions'));
const AttendancePage = lazy(() => import('./pages/Attendance'));
const AttendanceDevicesPage = lazy(() => import('./pages/AttendanceDevices'));
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
const NotificationsPage = lazy(() => import('./pages/Notifications'));
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
@@ -240,6 +241,16 @@ const App: React.FC = () => {
}
/>
<Route
path="attendance-devices"
element={
<PermissionRoute permission="classroom:view">
<AttendanceDevicesPage />
</PermissionRoute>
}
/>
<Route
path="attendance"
element={

View File

@@ -85,6 +85,7 @@ const SECTIONS: MenuSection[] = [
children: [
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
{ key: '/attendance-devices', label: '考勤机绑定', icon: 'attendance', permission: 'classroom:view' },
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
],

View File

@@ -20,6 +20,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
{ path: '/schedules', permission: 'schedule:view' },
{ path: '/classroom-schedule', permission: 'rental:view' },
{ path: '/classrooms', permission: 'classroom:view' },
{ path: '/attendance-devices', permission: 'classroom:view' },
{ path: '/classroom-rentals', permission: 'rental:view' },
{ path: '/organizations', permission: 'organization:view' },
{ path: '/expenses', permission: 'expense:view' },

View File

@@ -0,0 +1,215 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
import api from '../api';
import PermissionButton from '../components/PermissionButton';
import { message } from '../ui/app-message';
interface ClassroomOption {
id: number;
name: string;
building?: string | null;
}
interface AttendanceDeviceRow {
id: number;
deviceSn: string;
deviceName: string;
classroomId: number;
classroom?: ClassroomOption | null;
status: 'active' | 'disabled';
location?: string | null;
notes?: string | null;
}
const statusMeta = {
active: { text: '启用', color: 'green' },
disabled: { text: '停用', color: 'default' },
} as const;
const AttendanceDevicesPage: React.FC = () => {
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [form] = Form.useForm();
const loadData = async () => {
setLoading(true);
try {
const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'),
]);
setData(devices);
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
const classroomOptions = useMemo(
() => classrooms.map((item) => ({
value: item.id,
label: item.building ? `${item.name}${item.building}` : item.name,
})),
[classrooms],
);
const filteredData = useMemo(() => {
const text = keyword.trim().toLocaleLowerCase('zh-CN');
if (!text) return data;
return data.filter((item) => [
item.deviceSn,
item.deviceName,
item.classroom?.name,
item.location,
].some((value) => (value || '').toLocaleLowerCase('zh-CN').includes(text)));
}, [data, keyword]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ status: 'active' });
setModalOpen(true);
};
const openEdit = (record: AttendanceDeviceRow) => {
setEditing(record);
form.setFieldsValue({
deviceSn: record.deviceSn,
deviceName: record.deviceName,
classroomId: record.classroomId,
status: record.status,
location: record.location,
notes: record.notes,
});
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/attendance-devices/${editing.id}`, values);
message.success('考勤机绑定已更新');
} else {
await api.post('/attendance-devices', values);
message.success('考勤机绑定已创建');
}
setModalOpen(false);
setEditing(null);
form.resetFields();
await loadData();
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/attendance-devices/${id}`);
message.success('已删除绑定');
await loadData();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
};
const columns: ColumnsType<AttendanceDeviceRow> = [
{ title: '设备名称', dataIndex: 'deviceName', width: 180 },
{ title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => <span style={{ fontFamily: 'monospace' }}>{value}</span> },
{ title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` },
{ title: '位置', dataIndex: 'location', render: (value) => value || <span style={{ color: '#999' }}></span> },
{ title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => <Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag> },
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || <span style={{ color: '#999' }}></span> },
{
title: '操作',
width: 150,
render: (_, record) => (
<Space>
<PermissionButton permission="classroom:edit" size="small" type="link" onClick={() => openEdit(record)}>
</PermissionButton>
<Popconfirm title="确定删除此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="classroom:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Input.Search
allowClear
placeholder="搜索设备/SN/教室"
style={{ width: 260 }}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<PermissionButton permission="classroom:edit" type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</PermissionButton>
</div>
<Table<AttendanceDeviceRow>
rowKey="id"
columns={columns}
dataSource={filteredData}
loading={loading}
locale={{ emptyText: <Empty description="暂无考勤机绑定" /> }}
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/>
<Modal
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="deviceName" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="如彼岸游境_N1604" />
</Form.Item>
<Form.Item name="deviceSn" label="SN 码" rules={[{ required: true, message: '请输入钉钉返回的 deviceSN' }]}>
<Input placeholder="如300419260325WN1604" />
</Form.Item>
<Form.Item name="classroomId" label="绑定教室" rules={[{ required: true, message: '请选择绑定教室' }]}>
<Select showSearch optionFilterProp="label" options={classroomOptions} placeholder="选择教室" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '停用' }]} />
</Form.Item>
<Form.Item name="location" label="位置">
<Input placeholder="如:教学楼一楼东侧" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AttendanceDevicesPage;

View File

@@ -27,7 +27,10 @@ const WalletsPage: React.FC = () => {
const [transactions, setTransactions] = useState<any[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const [batchForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const fetchRows = useCallback(async () => {
setLoading(true);
@@ -46,6 +49,11 @@ const WalletsPage: React.FC = () => {
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
};
const openBatchChange = () => {
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
setBatchModalOpen(true);
};
const submitChange = async () => {
if (!selected) return;
const values = await form.validateFields();
@@ -60,6 +68,30 @@ const WalletsPage: React.FC = () => {
finally { setSaving(false); }
};
const submitBatchChange = async () => {
const values = await batchForm.validateFields();
setSaving(true);
try {
const result: any = await api.post('/wallets/batch-change-balance', {
studentIds: selectedRowKeys,
...values,
});
const paid = (result.results || []).reduce((sum: number, item: any) => {
return sum + (item.payments || []).reduce((paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0), 0);
}, 0);
message.success(
paid > 0
? `已批量更新 ${selectedRowKeys.length} 名学生余额,并自动补扣历史账单`
: `已批量更新 ${selectedRowKeys.length} 名学生余额`,
);
setBatchModalOpen(false);
setSelectedRowKeys([]);
batchForm.resetFields();
await fetchRows();
} catch (error: any) { message.error(error?.message || '批量余额操作失败'); }
finally { setSaving(false); }
};
const showTransactions = async (row: WalletRow) => {
setSelected(row); setDrawerOpen(true);
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
@@ -76,9 +108,19 @@ const WalletsPage: React.FC = () => {
return <div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span></span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
<Button icon={<ReloadOutlined />} onClick={fetchRows}></Button>
<Space wrap>
<PermissionButton permission="wallet:edit" type="primary" icon={<PlusOutlined />} disabled={selectedRowKeys.length === 0} onClick={openBatchChange}>/</PermissionButton>
<Button icon={<ReloadOutlined />} onClick={fetchRows}></Button>
</Space>
</div>
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} />
<Table
rowKey="studentId"
loading={loading}
dataSource={rows}
columns={columns}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
<Form form={form} layout="vertical">
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
@@ -86,6 +128,27 @@ const WalletsPage: React.FC = () => {
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Modal
title={`批量余额操作(${selectedRowKeys.length} 人)`}
open={batchModalOpen}
onCancel={() => setBatchModalOpen(false)}
onOk={submitBatchChange}
confirmLoading={saving}
okText="确认批量修改"
>
<Form form={batchForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
<strong>{selectedRowKeys.length}</strong>
</div>
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}>
<Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} />
</Form.Item>
<Form.Item name="amount" label="变动金额(元/人)" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}>
<InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" />
</Form.Item>
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
</Form>
</Modal>
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },

View File

@@ -22,7 +22,7 @@ JWT_EXPIRES_IN=24h
ADMIN_PASSWORD=请替换为强密码
# ---- 服务端口 ----
PORT=3000
PORT=3002
# ---- 文件上传 ----
# 合同 PDF 存储根目录(相对或绝对)

View File

@@ -30,6 +30,7 @@ import {
ClassSchedule,
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
DingAttendanceRaw,
SyncLog,
SyncState,
@@ -64,6 +65,7 @@ import { ClassroomsModule } from './classrooms/classrooms.module';
import { ClassesModule } from './classes/classes.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { AttendanceModule } from './attendance/attendance.module';
import { AttendanceDevicesModule } from './attendance-devices/attendance-devices.module';
import { SchedulesModule } from './schedules/schedules.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
import { SyncModule } from './sync/sync.module';
@@ -123,6 +125,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
ClassSchedule,
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
DingAttendanceRaw,
Notification,
StudentProfile,
@@ -176,6 +179,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
WalletsModule,
ClassroomsModule,
AttendanceModule,
AttendanceDevicesModule,
ClassesModule,
OrganizationsModule,
SchedulesModule,

View File

@@ -0,0 +1,85 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, Request, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { AttendanceDevicesService } from './attendance-devices.service';
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
import { AttendanceDeviceStatus } from '../entities';
@UseGuards(JwtAuthGuard)
@Controller('attendance-devices')
export class AttendanceDevicesController {
constructor(
private readonly service: AttendanceDevicesService,
private readonly logService: OperationLogsService,
) {}
@Get()
@RequirePermission('classroom:view')
findAll(
@Query('classroomId') classroomId?: string,
@Query('status') status?: AttendanceDeviceStatus | 'active' | 'disabled',
) {
return this.service.findAll({
classroomId: classroomId ? Number(classroomId) : undefined,
status,
});
}
@Post()
@RequirePermission('classroom:edit')
async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤机',
action: '新增考勤机绑定',
targetId: result.id,
targetType: 'attendanceDevice',
detail: `${result.deviceSn} -> ${result.classroom?.name || result.classroomId}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id')
@RequirePermission('classroom:edit')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤机',
action: '编辑考勤机绑定',
targetId: id,
targetType: 'attendanceDevice',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('classroom:edit')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '考勤机',
action: '删除考勤机绑定',
targetId: id,
targetType: 'attendanceDevice',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceDevice, Classroom } from '../entities';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { AttendanceDevicesController } from './attendance-devices.controller';
import { AttendanceDevicesService } from './attendance-devices.service';
@Module({
imports: [TypeOrmModule.forFeature([AttendanceDevice, Classroom]), OperationLogsModule],
controllers: [AttendanceDevicesController],
providers: [AttendanceDevicesService],
exports: [AttendanceDevicesService],
})
export class AttendanceDevicesModule {}

View File

@@ -0,0 +1,105 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AttendanceDevice, AttendanceDeviceStatus, Classroom } from '../entities';
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
@Injectable()
export class AttendanceDevicesService {
constructor(
@InjectRepository(AttendanceDevice)
private readonly repo: Repository<AttendanceDevice>,
@InjectRepository(Classroom)
private readonly classroomRepo: Repository<Classroom>,
) {}
private normalizeSn(sn: string): string {
return sn.trim();
}
private async assertClassroomExists(classroomId: number): Promise<void> {
const exists = await this.classroomRepo.exist({ where: { id: classroomId } });
if (!exists) throw new BadRequestException('绑定教室不存在');
}
async findAll(query?: { classroomId?: number; status?: AttendanceDeviceStatus | 'active' | 'disabled' }) {
const where: Record<string, unknown> = {};
if (query?.classroomId) where.classroomId = query.classroomId;
if (query?.status) where.status = query.status;
return this.repo.find({
where,
relations: ['classroom'],
order: { classroomId: 'ASC', deviceName: 'ASC' },
});
}
async findOne(id: number) {
const device = await this.repo.findOne({ where: { id }, relations: ['classroom'] });
if (!device) throw new NotFoundException('考勤机不存在');
return device;
}
async create(dto: CreateAttendanceDeviceDto) {
const deviceSn = this.normalizeSn(dto.deviceSn);
await this.assertClassroomExists(dto.classroomId);
const exists = await this.repo.findOne({ where: { deviceSn } });
if (exists) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
const saved = await this.repo.save(
this.repo.create({
...dto,
deviceSn,
deviceName: dto.deviceName.trim(),
status: dto.status ?? AttendanceDeviceStatus.ACTIVE,
}),
);
return this.findOne(saved.id);
}
async update(id: number, dto: UpdateAttendanceDeviceDto) {
const device = await this.repo.findOne({ where: { id } });
if (!device) throw new NotFoundException('考勤机不存在');
const patch: Partial<AttendanceDevice> = { ...dto };
if (dto.classroomId != null) await this.assertClassroomExists(dto.classroomId);
if (dto.deviceSn != null) {
const deviceSn = this.normalizeSn(dto.deviceSn);
const exists = await this.repo.findOne({ where: { deviceSn } });
if (exists && exists.id !== id) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
patch.deviceSn = deviceSn;
}
if (dto.deviceName != null) patch.deviceName = dto.deviceName.trim();
await this.repo.update(id, patch);
return this.findOne(id);
}
async remove(id: number) {
const device = await this.repo.findOne({ where: { id } });
if (!device) throw new NotFoundException('考勤机不存在');
await this.repo.delete(id);
return { message: '已删除' };
}
async findActiveBySn(deviceSns: string[]) {
const sns = [...new Set(deviceSns.map((sn) => this.normalizeSn(sn)).filter(Boolean))];
if (sns.length === 0) return new Map<string, AttendanceDevice>();
const devices = await this.repo.find({
where: { deviceSn: In(sns), status: AttendanceDeviceStatus.ACTIVE },
relations: ['classroom'],
});
return new Map(devices.map((device) => [device.deviceSn, device]));
}
async findActiveByClassroomIds(classroomIds: number[]) {
const ids = [...new Set(classroomIds.filter((id) => Number.isFinite(id)))];
if (ids.length === 0) return new Map<number, AttendanceDevice>();
const devices = await this.repo.find({
where: { classroomId: In(ids), status: AttendanceDeviceStatus.ACTIVE },
relations: ['classroom'],
order: { id: 'ASC' },
});
const result = new Map<number, AttendanceDevice>();
for (const device of devices) {
if (!result.has(device.classroomId)) result.set(device.classroomId, device);
}
return result;
}
}

View File

@@ -0,0 +1,61 @@
import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { AttendanceDeviceStatus } from '../../entities/attendance-device.entity';
export class CreateAttendanceDeviceDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceSn: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceName: string;
@IsInt()
classroomId: number;
@IsOptional()
@IsEnum(AttendanceDeviceStatus)
status?: AttendanceDeviceStatus;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateAttendanceDeviceDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceSn?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
deviceName?: string;
@IsOptional()
@IsInt()
classroomId?: number;
@IsOptional()
@IsEnum(AttendanceDeviceStatus)
status?: AttendanceDeviceStatus;
@IsOptional()
@IsString()
@MaxLength(200)
location?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service';
@@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module';
@Module({
imports: [
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
OperationLogsModule,
IntegrationModule,
],

View File

@@ -4,6 +4,7 @@ import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource }
import {
AttendanceRecord,
AttendanceSession,
AttendanceDevice,
DingAttendanceRaw,
Class,
Student,
@@ -66,11 +67,71 @@ export class AttendanceService {
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice)
private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
private formatDeviceDetail(device: AttendanceDevice): string {
const classroomName = device.classroom?.name;
return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName;
}
private async attachAttendanceDeviceMappings<T extends AttendanceRecord>(
records: T[],
classroomId?: number | null,
): Promise<T[]> {
if (records.length === 0) return records;
const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])];
const devicesBySn = new Map<string, AttendanceDevice>();
if (sns.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { deviceSn: In(sns), status: 'active' },
relations: ['classroom'],
});
for (const device of devices) devicesBySn.set(device.deviceSn, device);
}
const classroomIds = [...new Set([
...records.map((record) => record.classId).filter((id): id is number => id != null),
...(classroomId != null ? [classroomId] : []),
])];
const devicesByClassroom = new Map<number, AttendanceDevice>();
if (classroomIds.length > 0) {
const devices = await this.attendanceDeviceRepo.find({
where: { classroomId: In(classroomIds), status: 'active' },
relations: ['classroom'],
order: { id: 'ASC' },
});
for (const device of devices) {
if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device);
}
}
for (const record of records) {
const sn = record.punchDeviceId?.trim();
const mappedBySn = sn ? devicesBySn.get(sn) : undefined;
if (mappedBySn) {
record.punchDeviceName = this.formatDeviceDetail(mappedBySn);
record.punchDeviceId = mappedBySn.deviceSn;
continue;
}
const source = (record.punchSource || '').trim().toUpperCase();
const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const fallbackClassroomId = record.classId ?? classroomId ?? undefined;
const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined;
if (isMachine && mappedByClassroom && !record.punchDeviceName) {
record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom);
record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn;
}
}
return records;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -172,7 +233,7 @@ export class AttendanceService {
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records };
return { schedule, session, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
}
private getLessonAttendanceWindow(
@@ -299,7 +360,7 @@ export class AttendanceService {
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session: existing, records };
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) };
}
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
throw new BadRequestException('课程考勤正在结算');
@@ -385,7 +446,7 @@ export class AttendanceService {
existing.completedAt = new Date();
await sessionRepo.save(existing);
}
return { schedule, session: existing, records: saved };
return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
});
}
@@ -428,7 +489,7 @@ export class AttendanceService {
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: existingRecords };
return { schedule, session, records: await this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) };
}
}
throw err;
@@ -469,7 +530,7 @@ export class AttendanceService {
session.completedAt = new Date();
session = await sessionRepo.save(session);
}
return { schedule, session, records: saved };
return { schedule, session, records: await this.attachAttendanceDeviceMappings(saved, schedule.classId) };
});
}
@@ -516,7 +577,7 @@ export class AttendanceService {
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records };
return { session, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
}
const pendingRecords = await recordRepo.count({
@@ -535,7 +596,7 @@ export class AttendanceService {
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records };
return { session: savedSession, records: await this.attachAttendanceDeviceMappings(records, session.classId) };
}),
);
}

View File

@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema();
await this.ensureAttendanceDevicesSchema();
await this.ensureStudentWalletSchema();
await this.backfillOrganizations();
await this.normalizeClassDates();
@@ -22,6 +23,64 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassroomStatuses();
}
private async ensureAttendanceDevicesSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const isMySQL = this.dataSource.options.type === 'mysql';
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
id ${pk},
device_sn VARCHAR(100) NOT NULL,
device_name VARCHAR(100) NOT NULL,
classroom_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
location VARCHAR(200),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const table = await runner.getTable('attendance_devices');
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
const additions: Array<[string, string]> = [
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
['location', 'VARCHAR(200)'],
['notes', 'TEXT'],
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
];
for (const [name, definition] of additions) {
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
}
const refreshed = await runner.getTable('attendance_devices');
const createIndex = async (sql: string) => {
try {
await runner.query(sql);
} catch {
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
}
};
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
if (!uniqueSn) {
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
);
}
await createIndex(
isMySQL
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
);
} finally {
await runner.release();
}
}
private async ensureStudentWalletSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
@@ -456,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type;
const dateExpression = (column: string) =>
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const table = await runner.getTable('classes');
if (!table) return;
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
// This cleanup is only for legacy schemas that stored dates as strings;
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
// in strict SQL mode.
if (driver === 'mysql') {
columns = columns.filter((columnName) => {
const column = table.columns.find((item) => item.name === columnName);
const type = String(column?.type ?? '').toLowerCase();
return !['date', 'datetime', 'timestamp'].includes(type);
});
if (columns.length === 0) return;
}
} finally {
await runner.release();
}
const columnText = (column: string) =>
driver === 'mysql' ? `CAST(${column} AS CHAR)` : column;
const firstTenChars = (column: string) =>
driver === 'mysql'
? `NULLIF(LEFT(${columnText(column)}, 10), '')`
: `NULLIF(substr(${column}, 1, 10), '')`;
const normalizedDate = (column: string) => `CASE
WHEN ${column} IS NULL THEN NULL
ELSE ${firstTenChars(column)}
END`;
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
const needsNormalization = (column: string) => `(
${column} IS NOT NULL
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
)`;
const assignments = columns
.map((column) => `${column} = ${normalizedDate(column)}`)
.join(',\n ');
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
const result = await this.dataSource.transaction((manager) =>
manager.query(`
UPDATE classes
SET
start_date = CASE
WHEN start_date IS NULL OR start_date = '' THEN start_date
ELSE ${dateExpression('start_date')}
END,
end_date = CASE
WHEN end_date IS NULL OR end_date = '' THEN end_date
ELSE ${dateExpression('end_date')}
END
${assignments}
WHERE
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
${predicates}
`),
);

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Classroom } from './classroom.entity';
export enum AttendanceDeviceStatus {
ACTIVE = 'active',
DISABLED = 'disabled',
}
@Entity('attendance_devices')
@Index(['deviceSn'], { unique: true })
@Index(['classroomId'])
export class AttendanceDevice {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'device_sn', type: 'varchar', length: 100 })
deviceSn: string;
@Column({ name: 'device_name', type: 'varchar', length: 100 })
deviceName: string;
@Column({ name: 'classroom_id', type: 'integer' })
classroomId: number;
@ManyToOne(() => Classroom, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'classroom_id' })
classroom: Classroom;
@Column({ type: 'varchar', length: 20, default: AttendanceDeviceStatus.ACTIVE })
status: AttendanceDeviceStatus | 'active' | 'disabled';
@Column({ type: 'varchar', length: 200, nullable: true })
location: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -14,7 +14,7 @@ export class ExpenseType {
@Column({ length: 20, default: 'room' })
category: string;
@Column({ type: 'integer', default: 0 })
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@Column({ default: true })

View File

@@ -22,6 +22,7 @@ export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
export { ClassSchedule, ScheduleType } from './class-schedule.entity';
export { AttendanceRecord } from './attendance-record.entity';
export { AttendanceSession } from './attendance-session.entity';
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity';

View File

@@ -515,7 +515,7 @@ export class DingTalkService {
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number;
deviceName?: string; deviceId?: string | number; deviceSN?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
@@ -535,7 +535,7 @@ export class DingTalkService {
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? r.deviceSN ?? '') || undefined,
}));
}

View File

@@ -1,4 +1,5 @@
import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator';
export class ChangeWalletBalanceDto {
@IsInt()
@@ -16,3 +17,24 @@ export class ChangeWalletBalanceDto {
@MaxLength(300)
description?: string;
}
export class BatchChangeWalletBalanceDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@Type(() => Number)
studentIds: number[];
@IsNumber({ maxDecimalPlaces: 2 })
@NotEquals(0)
amount: number;
@IsIn(['recharge', 'adjustment'])
type: 'recharge' | 'adjustment';
@IsOptional()
@IsString()
@MaxLength(300)
description?: string;
}

View File

@@ -3,7 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { WalletsService } from './wallets.service';
@UseGuards(JwtAuthGuard)
@@ -41,4 +41,24 @@ export class WalletsController {
});
return result;
}
@Post('batch-change-balance')
@RequirePermission('wallet:edit')
async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: any) {
const result = await this.service.batchChangeBalance(dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生余额',
action: dto.type === 'recharge' ? '批量余额充值' : '批量余额调账',
targetId: undefined,
targetType: 'student_wallet',
detail: `学生${result.count}人,金额 ¥${dto.amount}${dto.description ? `${dto.description}` : ''}`,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -6,7 +6,7 @@ import { Student } from '../entities/student.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { In } from 'typeorm';
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -92,6 +92,20 @@ export class WalletsService {
});
}
async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) {
const uniqueStudentIds = Array.from(new Set(dto.studentIds));
const results: Awaited<ReturnType<WalletsService['changeBalance']>>[] = [];
for (const studentId of uniqueStudentIds) {
results.push(await this.changeBalance({
studentId,
amount: dto.amount,
type: dto.type,
description: dto.description,
}, recordedBy));
}
return { count: uniqueStudentIds.length, results };
}
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
if (bill.status === 'cancelled') return bill;

View File

@@ -7,13 +7,14 @@ services:
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
MYSQL_DATABASE: gongxue
MYSQL_DATABASE: dorm_billing_v2
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
- "127.0.0.1:3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
@@ -32,8 +33,8 @@ services:
DB_PORT: 3306
DB_USERNAME: root
DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
DB_DATABASE: gongxue
DB_SYNCHRONIZE: "true"
DB_DATABASE: dorm_billing_v2
DB_SYNCHRONIZE: "false"
JWT_SECRET: ${JWT_SECRET:-gongxue-jwt-prod-2026-k3y}
JWT_EXPIRES_IN: 24h
PORT: 3000

598
docker/mysql/init.sql Normal file
View File

@@ -0,0 +1,598 @@
-- 恭学教育 MySQL 初始化脚本
-- 用途:在 DB_SYNCHRONIZE=false 时初始化空 MySQL 8.x 数据库,使当前项目可直接运行。
-- 默认管理员admin / admin123首次登录后请立即修改密码
-- 如果用 docker-entrypoint-initdb.d 自动初始化,可直接挂载本文件。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE DATABASE IF NOT EXISTS `dorm_billing_v2` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `dorm_billing_v2`;
-- ---------- 重置旧表 ----------
DROP TABLE IF EXISTS `archive_attachments`;
DROP TABLE IF EXISTS `result_archives`;
DROP TABLE IF EXISTS `learning_records`;
DROP TABLE IF EXISTS `exam_scores`;
DROP TABLE IF EXISTS `student_enrollments`;
DROP TABLE IF EXISTS `student_profiles`;
DROP TABLE IF EXISTS `notifications`;
DROP TABLE IF EXISTS `ding_attendance_raw`;
DROP TABLE IF EXISTS `attendance_devices`;
DROP TABLE IF EXISTS `attendance_records`;
DROP TABLE IF EXISTS `attendance_sessions`;
DROP TABLE IF EXISTS `class_schedule`;
DROP TABLE IF EXISTS `class_teacher`;
DROP TABLE IF EXISTS `class_student`;
DROP TABLE IF EXISTS `classes`;
DROP TABLE IF EXISTS `classroom_rentals`;
DROP TABLE IF EXISTS `classrooms`;
DROP TABLE IF EXISTS `deposit_installments`;
DROP TABLE IF EXISTS `deposits`;
DROP TABLE IF EXISTS `operation_logs`;
DROP TABLE IF EXISTS `student_wallets`;
DROP TABLE IF EXISTS `wallet_transactions`;
DROP TABLE IF EXISTS `student_ding_mapping`;
DROP TABLE IF EXISTS `students`;
DROP TABLE IF EXISTS `user_roles`;
DROP TABLE IF EXISTS `role_permissions`;
DROP TABLE IF EXISTS `users`;
DROP TABLE IF EXISTS `roles`;
DROP TABLE IF EXISTS `permissions`;
DROP TABLE IF EXISTS `bills`;
DROP TABLE IF EXISTS `bill_items`;
DROP TABLE IF EXISTS `personal_expenses`;
DROP TABLE IF EXISTS `room_expenses`;
DROP TABLE IF EXISTS `occupancies`;
DROP TABLE IF EXISTS `beds`;
DROP TABLE IF EXISTS `lockers`;
DROP TABLE IF EXISTS `rooms`;
DROP TABLE IF EXISTS `organizations`;
DROP TABLE IF EXISTS `sync_state`;
DROP TABLE IF EXISTS `sync_logs`;
DROP TABLE IF EXISTS `expense_types`;
DROP TABLE IF EXISTS `ai_config`;
DROP TABLE IF EXISTS `integration_config_detail`;
DROP TABLE IF EXISTS `integration_config`;
-- ---------- 表结构 ----------
CREATE TABLE IF NOT EXISTS `room_expenses` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `period_start` date NOT NULL, `period_end` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `rooms` (`id` int NOT NULL AUTO_INCREMENT, `room_number` varchar(20) NOT NULL, `building` varchar(50) NULL, `floor` int NULL, `capacity` int NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `room_type` varchar(20) NULL, `rental_category` varchar(10) NOT NULL DEFAULT 'short', `monthly_rate` decimal(10,2) NOT NULL DEFAULT 0.00, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_8f7c6fa4c469bab1a06fe3e49f` (`room_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `organizations` (`id` int NOT NULL AUTO_INCREMENT, `public_id` varchar(36) NOT NULL, `code` varchar(50) NOT NULL, `name` varchar(100) NOT NULL, `is_host` tinyint NOT NULL DEFAULT 0, `contact_name` varchar(50) NULL, `phone` varchar(30) NULL, `color` varchar(20) NULL, `notes` text NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0db5eda192bf60a02bd41931f8` (`public_id`), UNIQUE INDEX `IDX_7e27c3b62c681fbe3e2322535f` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `beds` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `bed_number` varchar(20) NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_95e9ba0a907346ef7b0d5ca488` (`room_id`, `bed_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `lockers` (`id` int NOT NULL AUTO_INCREMENT, `room_id` int NOT NULL, `locker_number` varchar(20) NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_8bc984d80d58c6909f738d8282` (`room_id`, `locker_number`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `occupancies` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NOT NULL, `check_in_date` date NOT NULL, `check_out_date` date NULL, `billing_start_date` date NOT NULL, `billing_end_date` date NULL, `check_out_reason` varchar(100) NULL, `notes` text NULL, `bed_id` int NULL, `locker_id` int NULL, `stay_type` varchar(10) NOT NULL DEFAULT 'short', `responsible_organization_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `personal_expenses` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `room_id` int NULL, `expense_type` varchar(20) NOT NULL, `amount` decimal(10,2) NOT NULL, `expense_date` date NOT NULL, `description` text NULL, `recorded_by` int NULL, `bill_id` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `bill_items` (`id` int NOT NULL AUTO_INCREMENT, `bill_id` int NOT NULL, `room_id` int NULL, `expense_type` varchar(20) NULL, `description` varchar(200) NULL, `days` int NULL, `total_room_days` int NULL, `room_total_amount` decimal(10,2) NULL, `student_amount` decimal(10,2) NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `bills` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `period_start` date NOT NULL, `period_end` date NOT NULL, `shared_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `personal_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `total_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `source` varchar(30) NOT NULL DEFAULT 'batch', `paid_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `outstanding_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `status` varchar(20) NOT NULL DEFAULT 'unpaid', `cancelled_at` datetime NULL, `cancel_reason` varchar(300) NULL, `generated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `permissions` (`id` int NOT NULL AUTO_INCREMENT, `code` varchar(50) NOT NULL, `name` varchar(50) NOT NULL, `group` varchar(30) NOT NULL, `description` varchar(200) NULL, UNIQUE INDEX `IDX_8dad765629e83229da6feda1c1` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `roles` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(30) NOT NULL, `code` varchar(30) NULL, `description` varchar(200) NULL, `is_system` tinyint NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_648e3f5447f725579d7d4ffdfb` (`name`), UNIQUE INDEX `IDX_f6d54f95c31b73fb1bdd8e91d0` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `users` (`id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password_hash` varchar(255) NOT NULL, `name` varchar(50) NULL, `is_active` tinyint NOT NULL DEFAULT 1, `last_login_at` datetime NULL, `is_archived` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `profile` text NULL, UNIQUE INDEX `IDX_fe0bb3f6520ee0469504521e71` (`username`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `students` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `student_no` varchar(30) NULL, `phone` varchar(20) NULL, `id_number` varchar(30) NULL, `gender` varchar(10) NULL, `ethnicity` varchar(20) NULL, `emergency_contact` varchar(50) NULL, `emergency_phone` varchar(20) NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `supervisor` varchar(50) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `user_id` int NULL, `organization_id` int NULL, UNIQUE INDEX `IDX_fb3eff90b11bddf7285f9b4e28` (`user_id`), UNIQUE INDEX `REL_fb3eff90b11bddf7285f9b4e28` (`user_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `operation_logs` (`id` int NOT NULL AUTO_INCREMENT, `user_id` int NULL, `username` varchar(50) NULL, `module` varchar(50) NOT NULL, `action` varchar(50) NOT NULL, `target_id` int NULL, `target_type` varchar(50) NULL, `detail` text NULL, `ip_address` varchar(50) NULL, `user_agent` varchar(500) NULL, `status` varchar(20) NULL DEFAULT 'success', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `deposit_installments` (`id` int NOT NULL AUTO_INCREMENT, `deposit_id` int NOT NULL, `amount` decimal(10,2) NOT NULL, `due_date` date NOT NULL, `paid_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'pending', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `deposits` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `amount` decimal(10,2) NOT NULL DEFAULT '500.00', `status` varchar(20) NOT NULL DEFAULT 'paid', `paid_date` date NOT NULL, `refund_date` date NULL, `refund_amount` decimal(10,2) NULL, `deduction_amount` decimal(10,2) NOT NULL DEFAULT 0.00, `deduction_reason` text NULL, `notes` text NULL, `recorded_by` int NULL, `refunded_by` int NULL, `refunded_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `classrooms` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `building` varchar(50) NULL, `floor` int NULL, `capacity` int NOT NULL DEFAULT '30', `room_type` varchar(20) NOT NULL DEFAULT '', `status` varchar(20) NOT NULL DEFAULT 'available', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `attendance_devices` (`id` int NOT NULL AUTO_INCREMENT, `device_sn` varchar(100) NOT NULL, `device_name` varchar(100) NOT NULL, `classroom_id` int NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `location` varchar(200) NULL, `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_attendance_devices_device_sn` (`device_sn`), INDEX `IDX_attendance_devices_classroom_id` (`classroom_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `classroom_rentals` (`id` int NOT NULL AUTO_INCREMENT, `classroom_id` int NOT NULL, `lessor_organization_id` int NULL, `lessee_organization_id` int NULL, `start_date` date NOT NULL, `end_date` date NOT NULL, `contract_path` varchar(255) NULL, `contract_original_name` varchar(255) NULL, `daily_rate` decimal(10,2) NULL, `total_amount` decimal(10,2) NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `notes` text NULL, `created_by` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX `IDX_b748a951d00b3f0c2090d10397` (`classroom_id`, `start_date`, `end_date`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `classes` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `code` varchar(50) NOT NULL, `class_type` varchar(20) NOT NULL, `start_date` date NULL, `end_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'enrolling', `head_teacher_id` int NULL, `life_teacher_id` int NULL, `academic_teacher_id` int NULL, `max_students` int NOT NULL DEFAULT 0, `notes` text NULL, `is_archived` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_cf7491878e0fca859943862998` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `class_student` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NOT NULL, `student_id` int NOT NULL, `join_date` date NULL, `leave_date` date NULL, `status` varchar(10) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_b0ed786e05e93dd9bf77189af1` (`class_id`, `student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `class_teacher` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NOT NULL, `user_id` int NOT NULL, `role_type` varchar(30) NOT NULL, `subject` varchar(50) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0117e9171e1533ff7b5c63f5d6` (`class_id`, `user_id`, `role_type`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `class_schedule` (`id` int NOT NULL AUTO_INCREMENT, `class_id` int NULL, `classroom_id` int NOT NULL, `week_day` int NOT NULL, `start_time` varchar(5) NOT NULL, `end_time` varchar(5) NOT NULL, `attendance_advance_minutes` int NOT NULL DEFAULT '30', `start_date` date NOT NULL, `end_date` date NOT NULL, `subject` varchar(50) NOT NULL, `teacher_id` int NULL, `schedule_type` varchar(20) NOT NULL DEFAULT 'INTERNAL', `rental_id` int NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `attendance_sessions` (`id` int NOT NULL AUTO_INCREMENT, `schedule_id` int NOT NULL, `class_id` int NOT NULL, `lesson_date` date NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'in_progress', `started_by` int NULL, `started_at` datetime NULL, `completed_by` int NULL, `completed_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_d4288982a2baaa6085cf872990` (`schedule_id`, `lesson_date`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `attendance_records` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `class_id` int NULL, `schedule_id` int NULL, `attendance_session_id` int NULL, `attendance_date` date NOT NULL, `session` varchar(20) NOT NULL, `status` varchar(20) NOT NULL, `remark` varchar(200) NULL, `source` varchar(20) NOT NULL DEFAULT 'manual', `punch_time` datetime NULL, `punch_source` varchar(40) NULL, `punch_device_name` varchar(100) NULL, `punch_device_id` varchar(100) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_43ac61c4ffa4117b738dd3a1e4` (`attendance_session_id`, `student_id`), INDEX `IDX_6b8f69a76bea22962e54bd21f5` (`student_id`, `attendance_date`), INDEX `IDX_a8eee7b8a0e0af27e79cb27ccc` (`class_id`, `attendance_date`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `ding_attendance_raw` (`id` int NOT NULL AUTO_INCREMENT, `ding_user_id` varchar(100) NOT NULL, `user_name` varchar(100) NOT NULL, `attendance_date` date NOT NULL, `ding_id` varchar(100) NOT NULL, `check_in_time` datetime NULL, `check_out_time` datetime NULL, `attendance_type` varchar(20) NOT NULL, `time_result` varchar(20) NOT NULL, `location_result` varchar(20) NULL, `punch_source` varchar(40) NULL, `punch_device_name` varchar(100) NULL, `punch_device_id` varchar(100) NULL, `match_status` varchar(20) NOT NULL DEFAULT 'unmatched', `matched_student_id` int NULL, `raw_data` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX `IDX_d6680d5150f0bfa47dbc1fe96f` (`match_status`), INDEX `IDX_088de070f537b15ab7da255e5b` (`attendance_date`), UNIQUE INDEX `IDX_997849bb04ff69149fcf00a8d3` (`ding_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `sync_logs` (`id` int NOT NULL AUTO_INCREMENT, `platform` varchar(20) NOT NULL, `sync_type` varchar(20) NOT NULL, `status` varchar(20) NOT NULL, `records_count` int NOT NULL DEFAULT 0, `error_message` text NULL, `started_at` datetime NULL, `finished_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `sync_state` (`platform` varchar(20) NOT NULL, `last_sync_at` datetime NULL, PRIMARY KEY (`platform`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `expense_types` (`id` int NOT NULL AUTO_INCREMENT, `code` varchar(30) NOT NULL, `name` varchar(30) NOT NULL, `category` varchar(20) NOT NULL DEFAULT 'room', `sort_order` int NOT NULL DEFAULT 0, `enabled` tinyint NOT NULL DEFAULT 1, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_36eda3eb0f6740ecf2ba906012` (`code`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `notifications` (`id` int NOT NULL AUTO_INCREMENT, `recipient_id` int NOT NULL, `type` varchar(30) NOT NULL, `title` varchar(200) NOT NULL, `content` text NULL, `link` varchar(500) NULL, `is_read` tinyint NOT NULL DEFAULT 0, `read_at` datetime NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_profiles` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `target_college` varchar(100) NULL, `target_major` varchar(100) NULL, `subject_direction` varchar(50) NULL, `grade` varchar(20) NULL, `profile_date` date NULL, `notes` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_4cedc08d3dc1f2c2da8a12f7a8` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_enrollments` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `course_category` varchar(50) NULL, `class_type` varchar(50) NULL, `class_name` varchar(100) NULL, `head_teacher` varchar(50) NULL, `subject_teacher` varchar(50) NULL, `start_date` date NULL, `end_date` date NULL, `status` varchar(20) NOT NULL DEFAULT 'active', `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `exam_scores` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `enrollment_id` int NULL, `exam_type` varchar(50) NULL, `exam_name` varchar(100) NULL, `subject` varchar(50) NULL, `score` decimal(5,2) NULL, `class_avg` decimal(5,2) NULL, `rank` int NULL, `exam_date` date NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `learning_records` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `record_date` date NULL, `record_type` varchar(50) NULL, `content` text NULL, `follow_up_method` varchar(50) NULL, `next_step` text NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `result_archives` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `culture_final_score` decimal(5,2) NULL, `professional_final_score` decimal(5,2) NULL, `admission_status` varchar(50) NULL, `admitted_college` varchar(100) NULL, `admitted_major` varchar(100) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_377bba8eb6a027eecd9737d4ed` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `archive_attachments` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `category` varchar(50) NULL, `file_name` varchar(255) NULL, `file_path` varchar(500) NULL, `file_size` int NULL, `mime_type` varchar(100) NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_ding_mapping` (`id` int NOT NULL AUTO_INCREMENT, `ding_user_id` varchar(100) NOT NULL, `student_id` int NOT NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_0d1ec47e2f901d37e3b6e56331` (`ding_user_id`), UNIQUE INDEX `IDX_f9ba15ff04de8ffbd8679ae9db` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `ai_config` (`id` int NOT NULL AUTO_INCREMENT, `singleton_key` varchar(20) NOT NULL DEFAULT 'GLOBAL', `provider` varchar(50) NOT NULL DEFAULT 'OPENAI', `base_url` varchar(500) NULL, `encrypted_api_key` text NULL, `api_key_iv` varchar(50) NULL, `api_key_auth_tag` varchar(50) NULL, `key_last4` varchar(4) NULL, `default_model` varchar(100) NULL, `enabled` tinyint NOT NULL DEFAULT 0, `timeout_ms` int NOT NULL DEFAULT '30000', `verified` tinyint NOT NULL DEFAULT 0, `last_tested_at` datetime NULL, `last_test_latency_ms` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `uq_ai_config_singleton` (`singleton_key`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `student_wallets` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `balance` decimal(12,2) NOT NULL DEFAULT 0.00, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_07a434ad1a960d506386754d59` (`student_id`), UNIQUE INDEX `REL_07a434ad1a960d506386754d59` (`student_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `wallet_transactions` (`id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `bill_id` int NULL, `type` varchar(30) NOT NULL, `amount` decimal(12,2) NOT NULL, `balance_after` decimal(12,2) NOT NULL, `description` varchar(300) NULL, `recorded_by` int NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), INDEX `IDX_680bbd0275ac5e06c179f9b84c` (`student_id`, `created_at`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `integration_config` (`id` int NOT NULL AUTO_INCREMENT, `type` varchar(50) NOT NULL, `sync_resource` varchar(50) NULL, `is_sync` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `integration_config_detail` (`id` int NOT NULL AUTO_INCREMENT, `config_id` int NOT NULL, `name` varchar(100) NULL, `type` varchar(50) NOT NULL, `content` text NULL, `enable` tinyint NOT NULL DEFAULT 0, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX `IDX_a58106c4b876c86a5e085e4f42` (`config_id`), PRIMARY KEY (`id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `role_permissions` (`role_id` int NOT NULL, `permission_id` int NOT NULL, INDEX `IDX_178199805b901ccd220ab7740e` (`role_id`), INDEX `IDX_17022daf3f885f7d35423e9971` (`permission_id`), PRIMARY KEY (`role_id`, `permission_id`)) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS `user_roles` (`user_id` int NOT NULL, `role_id` int NOT NULL, INDEX `IDX_87b8888186ca9769c960e92687` (`user_id`), INDEX `IDX_b23c65e50a758245a33ee35fda` (`role_id`), PRIMARY KEY (`user_id`, `role_id`)) ENGINE=InnoDB;
-- ---------- 外键 ----------
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_178199805b901ccd220ab7740ec' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `role_permissions` ADD CONSTRAINT `FK_178199805b901ccd220ab7740ec` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_17022daf3f885f7d35423e9971e' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `role_permissions` ADD CONSTRAINT `FK_17022daf3f885f7d35423e9971e` FOREIGN KEY (`permission_id`) REFERENCES `permissions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_87b8888186ca9769c960e926870' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `user_roles` ADD CONSTRAINT `FK_87b8888186ca9769c960e926870` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_b23c65e50a758245a33ee35fda1' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `user_roles` ADD CONSTRAINT `FK_b23c65e50a758245a33ee35fda1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_7bb1ca73f8161af27e538917a55' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `room_expenses` ADD CONSTRAINT `FK_7bb1ca73f8161af27e538917a55` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_fd7413faee42749f1b2e8270394' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `beds` ADD CONSTRAINT `FK_fd7413faee42749f1b2e8270394` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_344ae29b9cacbee1e24da7c07ea' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `lockers` ADD CONSTRAINT `FK_344ae29b9cacbee1e24da7c07ea` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_bed301f5a4e2135dd14b55fec0e' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `occupancies` ADD CONSTRAINT `FK_bed301f5a4e2135dd14b55fec0e` FOREIGN KEY (`bed_id`) REFERENCES `beds`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_ea80244bd95c8c04334931e39a2' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `occupancies` ADD CONSTRAINT `FK_ea80244bd95c8c04334931e39a2` FOREIGN KEY (`locker_id`) REFERENCES `lockers`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_3c80232a56303a75ffd544b982f' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `occupancies` ADD CONSTRAINT `FK_3c80232a56303a75ffd544b982f` FOREIGN KEY (`responsible_organization_id`) REFERENCES `organizations`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_dc062720596049470b56ce06973' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `occupancies` ADD CONSTRAINT `FK_dc062720596049470b56ce06973` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_9f7acc7567e42dda281ead9a75a' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `occupancies` ADD CONSTRAINT `FK_9f7acc7567e42dda281ead9a75a` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_5e5bcfb70705c2c349c74b70912' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `personal_expenses` ADD CONSTRAINT `FK_5e5bcfb70705c2c349c74b70912` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_b424156152a3230b034bdb51db4' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `bill_items` ADD CONSTRAINT `FK_b424156152a3230b034bdb51db4` FOREIGN KEY (`bill_id`) REFERENCES `bills`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_65a6cb602ebcbac1bf7a995e0c9' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `bills` ADD CONSTRAINT `FK_65a6cb602ebcbac1bf7a995e0c9` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_fb3eff90b11bddf7285f9b4e281' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `students` ADD CONSTRAINT `FK_fb3eff90b11bddf7285f9b4e281` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_9571384818ecf499779d3a9d141' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `students` ADD CONSTRAINT `FK_9571384818ecf499779d3a9d141` FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_fba3c52b86d51fff34593877ff5' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `deposit_installments` ADD CONSTRAINT `FK_fba3c52b86d51fff34593877ff5` FOREIGN KEY (`deposit_id`) REFERENCES `deposits`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_ec1b340d2963ed907421696fc33' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `deposits` ADD CONSTRAINT `FK_ec1b340d2963ed907421696fc33` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_6a0af46fe476070c4c660307641' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `classroom_rentals` ADD CONSTRAINT `FK_6a0af46fe476070c4c660307641` FOREIGN KEY (`classroom_id`) REFERENCES `classrooms`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_40a46e95470ff454ffdcbb1e902' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `classroom_rentals` ADD CONSTRAINT `FK_40a46e95470ff454ffdcbb1e902` FOREIGN KEY (`lessor_organization_id`) REFERENCES `organizations`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_f60533a91f16a331c9388308418' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `classroom_rentals` ADD CONSTRAINT `FK_f60533a91f16a331c9388308418` FOREIGN KEY (`lessee_organization_id`) REFERENCES `organizations`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_60674dc4f23b794de0a3560e200' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_student` ADD CONSTRAINT `FK_60674dc4f23b794de0a3560e200` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_451334295b9cf221d55aca7cac1' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_student` ADD CONSTRAINT `FK_451334295b9cf221d55aca7cac1` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_9b647c8d8894a661dc1dac40361' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_teacher` ADD CONSTRAINT `FK_9b647c8d8894a661dc1dac40361` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_a9f0e77d193b015b6d5ca6637f5' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_teacher` ADD CONSTRAINT `FK_a9f0e77d193b015b6d5ca6637f5` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_attendance_devices_classroom' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_devices` ADD CONSTRAINT `FK_attendance_devices_classroom` FOREIGN KEY (`classroom_id`) REFERENCES `classrooms`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_0aa74ee427967d4c82f298511bc' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_schedule` ADD CONSTRAINT `FK_0aa74ee427967d4c82f298511bc` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_c1d5d50c871fdf563be13d6e93a' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_schedule` ADD CONSTRAINT `FK_c1d5d50c871fdf563be13d6e93a` FOREIGN KEY (`classroom_id`) REFERENCES `classrooms`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_f1e761cac59f2d0a3d7949468c5' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `class_schedule` ADD CONSTRAINT `FK_f1e761cac59f2d0a3d7949468c5` FOREIGN KEY (`teacher_id`) REFERENCES `users`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_107206b64415a644d571d597ca3' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_sessions` ADD CONSTRAINT `FK_107206b64415a644d571d597ca3` FOREIGN KEY (`schedule_id`) REFERENCES `class_schedule`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_6fc552e07b31ac92445cd3e21fa' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_sessions` ADD CONSTRAINT `FK_6fc552e07b31ac92445cd3e21fa` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_dbace05c012526710663f8d8911' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_records` ADD CONSTRAINT `FK_dbace05c012526710663f8d8911` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_cc48ccbd03396874857ab06ac3b' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_records` ADD CONSTRAINT `FK_cc48ccbd03396874857ab06ac3b` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_0dfe6d219c4a7a162ca0f84243c' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_records` ADD CONSTRAINT `FK_0dfe6d219c4a7a162ca0f84243c` FOREIGN KEY (`schedule_id`) REFERENCES `class_schedule`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_32a57ba853da6939ceaccf8cfc8' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `attendance_records` ADD CONSTRAINT `FK_32a57ba853da6939ceaccf8cfc8` FOREIGN KEY (`attendance_session_id`) REFERENCES `attendance_sessions`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_2b06491a70540db69e78e57ecf2' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `ding_attendance_raw` ADD CONSTRAINT `FK_2b06491a70540db69e78e57ecf2` FOREIGN KEY (`matched_student_id`) REFERENCES `students`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_5332a4daa46fd3f4e6625dd275d' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `notifications` ADD CONSTRAINT `FK_5332a4daa46fd3f4e6625dd275d` FOREIGN KEY (`recipient_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_4cedc08d3dc1f2c2da8a12f7a88' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `student_profiles` ADD CONSTRAINT `FK_4cedc08d3dc1f2c2da8a12f7a88` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_08caafd8a026a19ecf54db0e958' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `student_enrollments` ADD CONSTRAINT `FK_08caafd8a026a19ecf54db0e958` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_925e4f79518c947512cb600b452' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `exam_scores` ADD CONSTRAINT `FK_925e4f79518c947512cb600b452` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_b0d812b639f32939ea57b81a89f' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `exam_scores` ADD CONSTRAINT `FK_b0d812b639f32939ea57b81a89f` FOREIGN KEY (`enrollment_id`) REFERENCES `student_enrollments`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_61f4664f02d86245e1231493951' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `learning_records` ADD CONSTRAINT `FK_61f4664f02d86245e1231493951` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_377bba8eb6a027eecd9737d4ed6' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `result_archives` ADD CONSTRAINT `FK_377bba8eb6a027eecd9737d4ed6` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_0bc7365c2629d0c6dae8052fc25' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `archive_attachments` ADD CONSTRAINT `FK_0bc7365c2629d0c6dae8052fc25` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_f9ba15ff04de8ffbd8679ae9dbb' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `student_ding_mapping` ADD CONSTRAINT `FK_f9ba15ff04de8ffbd8679ae9dbb` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_07a434ad1a960d506386754d592' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `student_wallets` ADD CONSTRAINT `FK_07a434ad1a960d506386754d592` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @fk_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'FK_a58106c4b876c86a5e085e4f42b' AND CONSTRAINT_TYPE = 'FOREIGN KEY');
SET @sql := IF(@fk_exists = 0, 'ALTER TABLE `integration_config_detail` ADD CONSTRAINT `FK_a58106c4b876c86a5e085e4f42b` FOREIGN KEY (`config_id`) REFERENCES `integration_config`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET FOREIGN_KEY_CHECKS = 1;
-- ---------- 初始化数据 ----------
INSERT INTO `permissions` (`id`, `code`, `name`, `group`) VALUES
('1', 'dashboard:view', '查看数据面板', 'dashboard'),
('2', 'profile:view', '查看个人资料', 'profile'),
('3', 'notification:view', '查看通知', 'notification'),
('4', 'student:view', '查看学生管理', 'student'),
('5', 'student:basic-view', '查看学生基础信息', 'student-scope'),
('6', 'teacher-workspace:view', '查看教师工作台', 'teacher-workspace'),
('7', 'teacher:view', '查看教师', 'teacher'),
('8', 'teacher:edit', '编辑教师', 'teacher'),
('9', 'student:create', '新增学生', 'student'),
('10', 'student:edit', '编辑学生', 'student'),
('11', 'student:delete', '删除学生', 'student'),
('12', 'student:import', '导入学生', 'student'),
('13', 'student:export', '导出学生', 'student'),
('14', 'room:view', '查看宿舍', 'room'),
('15', 'room:create', '新增宿舍', 'room'),
('16', 'room:edit', '编辑宿舍', 'room'),
('17', 'room:delete', '删除宿舍', 'room'),
('18', 'occupancy:view', '查看入住', 'occupancy'),
('19', 'occupancy:checkin', '办理入住', 'occupancy'),
('20', 'occupancy:checkout', '办理退宿', 'occupancy'),
('21', 'occupancy:transfer', '调换宿舍', 'occupancy'),
('22', 'occupancy:delete', '删除入住记录', 'occupancy'),
('23', 'expense:view', '查看费用', 'expense'),
('24', 'expense:create', '录入费用', 'expense'),
('25', 'expense:edit', '编辑费用', 'expense'),
('26', 'expense:delete', '删除费用', 'expense'),
('27', 'bill:view', '查看账单', 'bill'),
('28', 'bill:generate', '生成账单', 'bill'),
('29', 'bill:confirm', '确认账单', 'bill'),
('30', 'bill:delete', '删除账单', 'bill'),
('31', 'bill:export-excel', '导出 Excel', 'bill'),
('32', 'bill:export-pdf', '导出 PDF', 'bill'),
('33', 'deposit:view', '查看押金', 'deposit'),
('34', 'deposit:create', '新增押金', 'deposit'),
('35', 'deposit:edit', '编辑押金', 'deposit'),
('36', 'deposit:delete', '删除押金', 'deposit'),
('37', 'deposit:refund', '直接退还押金', 'deposit'),
('38', 'wallet:view', '查看学生余额', 'wallet'),
('39', 'wallet:edit', '充值和调账', 'wallet'),
('40', 'classroom:view', '查看教室', 'classroom'),
('41', 'classroom:create', '新增教室', 'classroom'),
('42', 'classroom:edit', '编辑教室', 'classroom'),
('43', 'classroom:delete', '删除教室', 'classroom'),
('44', 'organization:view', '查看机构', 'organization'),
('45', 'organization:create', '新增机构', 'organization'),
('46', 'organization:edit', '编辑机构', 'organization'),
('47', 'organization:delete', '归档机构', 'organization'),
('48', 'rental:view', '查看租赁订单', 'rental'),
('49', 'rental:create', '新增租赁订单', 'rental'),
('50', 'rental:edit', '编辑租赁订单', 'rental'),
('51', 'rental:delete', '删除租赁订单', 'rental'),
('52', 'log:view', '查看操作日志', 'log'),
('53', 'log:create', '写入操作日志', 'log'),
('54', 'user:view', '查看用户', 'user'),
('55', 'user:create', '创建用户', 'user'),
('56', 'user:edit', '编辑用户', 'user'),
('57', 'user:delete', '删除用户', 'user'),
('58', 'user:reset-password', '重置密码', 'user'),
('59', 'role:view', '查看角色', 'role'),
('60', 'role:create', '创建角色', 'role'),
('61', 'role:edit', '编辑角色', 'role'),
('62', 'role:delete', '删除角色', 'role'),
('63', 'class:view', '查看班级', 'class'),
('64', 'class:create', '创建班级', 'class'),
('65', 'class:edit', '编辑班级', 'class'),
('66', 'class:delete', '删除班级', 'class'),
('67', 'schedule:view', '查看排课', 'schedule'),
('68', 'schedule:create', '创建排课', 'schedule'),
('69', 'schedule:edit', '编辑排课', 'schedule'),
('70', 'schedule:delete', '删除排课', 'schedule'),
('71', 'attendance:view', '查看考勤', 'attendance'),
('72', 'attendance:create', '新增考勤', 'attendance'),
('73', 'attendance:edit', '编辑全部考勤', 'attendance'),
('74', 'attendance:self-edit', '编辑任教班级考勤', 'attendance-scope'),
('75', 'attendance:export', '导出考勤', 'attendance'),
('76', 'attendance:generate', '按课表生成考勤', 'attendance'),
('77', 'learning:create', '创建学习任务', 'learning'),
('78', 'learning:edit', '编辑学习任务', 'learning'),
('79', 'learning:delete', '删除学习任务', 'learning'),
('80', 'exam:create', '创建考试', 'exam'),
('81', 'exam:edit', '编辑考试', 'exam'),
('82', 'exam:delete', '删除考试', 'exam'),
('83', 'sync:trigger', '触发数据同步', 'sync'),
('84', 'sync:read', '查看同步状态', 'sync'),
('85', 'integration:trigger', '触发集成', 'integration'),
('86', 'integration:read', '查看集成状态', 'integration'),
('87', 'department:view', '查看部门', 'department'),
('88', 'department:edit', '编辑部门', 'department'),
('89', 'department:delete', '删除部门', 'department'),
('90', 'ai:config:read', '查看 AI 配置', 'ai'),
('91', 'ai:config:write', '修改 AI 配置', 'ai'),
('92', 'ai:config:test', '测试 AI 连接', 'ai')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `group` = VALUES(`group`);
INSERT INTO `roles` (`id`, `name`, `code`, `description`, `is_system`, `status`) VALUES
('1', '超级管理员', 'super_admin', '系统初始化、应急维护和全局权限处理', '1', '1'),
('2', '任课老师', 'teacher', '查看自己的排课、今日课程和任教班级考勤', '1', '1'),
('3', '教务管理员', 'academic', '管理学生、班级、教师、全局排课和历史考勤', '1', '1'),
('4', '住宿运营管理员', 'accommodation_operations', '管理宿舍、入住、住宿费用、账单、押金和退宿结算', '1', '1'),
('5', '教室运营管理员', 'classroom_operations', '管理教室、教室排期、外部机构和租赁订单', '1', '1'),
('6', '系统管理员', 'system_admin', '管理账号、角色、日志、同步和系统配置', '1', '1')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `description` = VALUES(`description`), `is_system` = VALUES(`is_system`), `status` = VALUES(`status`);
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`) VALUES
('1', '1'),
('1', '2'),
('1', '3'),
('1', '4'),
('1', '5'),
('1', '6'),
('1', '7'),
('1', '8'),
('1', '9'),
('1', '10'),
('1', '11'),
('1', '12'),
('1', '13'),
('1', '14'),
('1', '15'),
('1', '16'),
('1', '17'),
('1', '18'),
('1', '19'),
('1', '20'),
('1', '21'),
('1', '22'),
('1', '23'),
('1', '24'),
('1', '25'),
('1', '26'),
('1', '27'),
('1', '28'),
('1', '29'),
('1', '30'),
('1', '31'),
('1', '32'),
('1', '33'),
('1', '34'),
('1', '35'),
('1', '36'),
('1', '37'),
('1', '38'),
('1', '39'),
('1', '40'),
('1', '41'),
('1', '42'),
('1', '43'),
('1', '44'),
('1', '45'),
('1', '46'),
('1', '47'),
('1', '48'),
('1', '49'),
('1', '50'),
('1', '51'),
('1', '52'),
('1', '53'),
('1', '54'),
('1', '55'),
('1', '56'),
('1', '57'),
('1', '58'),
('1', '59'),
('1', '60'),
('1', '61'),
('1', '62'),
('1', '63'),
('1', '64'),
('1', '65'),
('1', '66'),
('1', '67'),
('1', '68'),
('1', '69'),
('1', '70'),
('1', '71'),
('1', '72'),
('1', '73'),
('1', '74'),
('1', '75'),
('1', '76'),
('1', '77'),
('1', '78'),
('1', '79'),
('1', '80'),
('1', '81'),
('1', '82'),
('1', '83'),
('1', '84'),
('1', '85'),
('1', '86'),
('1', '87'),
('1', '88'),
('1', '89'),
('1', '90'),
('1', '91'),
('1', '92'),
('2', '6'),
('2', '67'),
('2', '71'),
('2', '72'),
('2', '74'),
('2', '2'),
('2', '3'),
('3', '6'),
('3', '7'),
('3', '8'),
('3', '84'),
('3', '83'),
('3', '1'),
('3', '2'),
('3', '3'),
('3', '4'),
('3', '9'),
('3', '10'),
('3', '11'),
('3', '12'),
('3', '13'),
('3', '40'),
('3', '41'),
('3', '42'),
('3', '43'),
('3', '63'),
('3', '64'),
('3', '65'),
('3', '66'),
('3', '67'),
('3', '68'),
('3', '69'),
('3', '70'),
('3', '71'),
('3', '72'),
('3', '73'),
('3', '75'),
('3', '76'),
('3', '77'),
('3', '78'),
('3', '79'),
('3', '80'),
('3', '81'),
('3', '82'),
('4', '5'),
('4', '1'),
('4', '2'),
('4', '3'),
('4', '14'),
('4', '15'),
('4', '16'),
('4', '17'),
('4', '18'),
('4', '19'),
('4', '20'),
('4', '21'),
('4', '22'),
('4', '23'),
('4', '24'),
('4', '25'),
('4', '26'),
('4', '27'),
('4', '28'),
('4', '29'),
('4', '30'),
('4', '31'),
('4', '32'),
('4', '33'),
('4', '34'),
('4', '35'),
('4', '36'),
('4', '37'),
('4', '38'),
('4', '39'),
('5', '2'),
('5', '3'),
('5', '40'),
('5', '41'),
('5', '42'),
('5', '43'),
('5', '44'),
('5', '45'),
('5', '46'),
('5', '47'),
('5', '48'),
('5', '49'),
('5', '50'),
('5', '51'),
('6', '2'),
('6', '3'),
('6', '52'),
('6', '53'),
('6', '54'),
('6', '55'),
('6', '56'),
('6', '57'),
('6', '58'),
('6', '59'),
('6', '60'),
('6', '61'),
('6', '62'),
('6', '83'),
('6', '84'),
('6', '85'),
('6', '86'),
('6', '87'),
('6', '88'),
('6', '89'),
('6', '90'),
('6', '91'),
('6', '92');
INSERT INTO `users` (`id`, `username`, `password_hash`, `name`, `is_active`, `is_archived`) VALUES
('1', 'admin', '$2b$10$vu8/qt4UCb6QnbmgQdbUtexPZWieuok030ZF0bI6xfpcf49MEML62', '管理员', '1', '0')
ON DUPLICATE KEY UPDATE `username` = `username`;
INSERT IGNORE INTO `user_roles` (`user_id`, `role_id`) VALUES (1, 1);
INSERT INTO `organizations` (`id`, `public_id`, `code`, `name`, `is_host`, `color`, `notes`, `status`) VALUES
('1', '01900000-0000-7000-8000-000000000001', 'HOST', '本机构', '1', '#1677ff', '系统默认运营主体', 'active')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `is_host` = VALUES(`is_host`), `status` = VALUES(`status`);
INSERT INTO `expense_types` (`code`, `name`, `category`, `sort_order`, `enabled`) VALUES
('water', '水费', 'room', '1', '1'),
('electricity', '电费', 'room', '2', '1'),
('cleaning', '保洁费', 'room', '3', '1'),
('damage', '损坏赔偿', 'both', '4', '1'),
('penalty', '罚款', 'personal', '5', '1'),
('key', '钥匙费', 'personal', '6', '1'),
('remote', '空调遥控器', 'personal', '7', '1'),
('deposit_deduction', '押金扣除', 'personal', '8', '1'),
('other', '其他', 'both', '99', '1')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `category` = VALUES(`category`), `sort_order` = VALUES(`sort_order`), `enabled` = VALUES(`enabled`);
INSERT INTO `ai_config` (`singleton_key`, `provider`, `enabled`, `timeout_ms`, `verified`) VALUES
('GLOBAL', 'OPENAI', '0', '30000', '0')
ON DUPLICATE KEY UPDATE `singleton_key` = `singleton_key`;
INSERT INTO `integration_config` (`id`, `type`, `is_sync`) VALUES
('1', 'THIRD', '0')
ON DUPLICATE KEY UPDATE `type` = VALUES(`type`);
-- 让后续 AUTO_INCREMENT 从安全位置继续。
ALTER TABLE `permissions` AUTO_INCREMENT = 1000;
ALTER TABLE `roles` AUTO_INCREMENT = 100;
ALTER TABLE `users` AUTO_INCREMENT = 100;
ALTER TABLE `organizations` AUTO_INCREMENT = 100;
ALTER TABLE `attendance_devices` AUTO_INCREMENT = 100;

View File

@@ -1,10 +1,10 @@
// PM2 进程配置 — 恭学教育
// 用法: pm2 start ecosystem.config.cjs
// 前提: 项目部署在 /opt/gongxuedeploy.sh 默认路径)
// .env 文件在 /opt/gongxue/.env
// docker compose up -d 已启动 MySQL
// 用法: pm2 startOrReload ecosystem.config.cjs --update-env
// 前提: 项目根目录执行;也可通过 DEPLOY_DIR 指定项目目录
// 生产环境配置写在项目根目录 .env由 Nest ConfigModule 读取
// 前端构建产物 apps/admin/dist 交给 Nginx 托管,不再用 PM2 启动前端
const DEPLOY_DIR = process.env.DEPLOY_DIR || '/opt/gongxue';
const DEPLOY_DIR = process.env.DEPLOY_DIR || __dirname;
module.exports = {
apps: [
@@ -16,19 +16,6 @@ module.exports = {
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: '3000',
DB_TYPE: 'mysql',
DB_HOST: '127.0.0.1',
DB_PORT: '3306',
DB_USERNAME: 'root',
DB_PASSWORD: 'gongxue_2024',
DB_DATABASE: 'gongxue',
DB_SYNCHRONIZE: 'true',
JWT_SECRET: 'gongxue-jwt-prod-2026-k3y',
JWT_EXPIRES_IN: '24h',
UPLOAD_DIR: './uploads',
DINGTALK_APP_KEY: 'dingvpcg2i6p25ftxw5c',
DINGTALK_APP_SECRET: 'ovAEql1r5Lu9FvBve6bHwBZxXAZuEQwSNpG3DkRJk4DMhQwJ_raKDQc6emdlJisU',
},
// 内存限制
max_memory_restart: '512M',
@@ -41,13 +28,5 @@ module.exports = {
autorestart: true,
watch: false,
},
{
name: 'gongxue-frontend',
cwd: DEPLOY_DIR,
script: 'serve-proxy.js',
env: {
NODE_ENV: 'production',
},
},
],
};