From 7a62d8962aeecca1034d7bc08650d81d90feddd9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 16 Jul 2026 10:21:40 +0800 Subject: [PATCH 1/4] chore: update production deployment config --- .env.example | 8 ++++++-- apps/server/.env.example | 2 +- docker-compose.yml | 7 ++++--- ecosystem.config.cjs | 31 +++++-------------------------- 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index e97c93d..dc86107 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/server/.env.example b/apps/server/.env.example index 2669e85..9004fdd 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -22,7 +22,7 @@ JWT_EXPIRES_IN=24h ADMIN_PASSWORD=请替换为强密码 # ---- 服务端口 ---- -PORT=3000 +PORT=3002 # ---- 文件上传 ---- # 合同 PDF 存储根目录(相对或绝对) diff --git a/docker-compose.yml b/docker-compose.yml index c17cc37..3e85551 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index d38e272..a1bce54 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,10 +1,10 @@ // PM2 进程配置 — 恭学教育 -// 用法: pm2 start ecosystem.config.cjs -// 前提: 项目部署在 /opt/gongxue(deploy.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', - }, - }, ], }; From 8ba51f48c0c88170102b0d57a61dbaa8702e34d7 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 16 Jul 2026 10:22:23 +0800 Subject: [PATCH 2/4] fix: map expense type sort order column --- apps/server/src/entities/expense-type.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/entities/expense-type.entity.ts b/apps/server/src/entities/expense-type.entity.ts index 1c4785c..e52bbba 100644 --- a/apps/server/src/entities/expense-type.entity.ts +++ b/apps/server/src/entities/expense-type.entity.ts @@ -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 }) From d0377873463845cd2985fe5e9509d85a8bcba66e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 16 Jul 2026 10:22:23 +0800 Subject: [PATCH 3/4] feat: support batch wallet balance changes --- apps/admin/src/pages/Wallets/index.tsx | 67 ++++++++++++++++++- apps/server/src/wallets/dto/wallet.dto.ts | 24 ++++++- apps/server/src/wallets/wallets.controller.ts | 22 +++++- apps/server/src/wallets/wallets.service.ts | 16 ++++- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 46f926f..9ac926f 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -27,7 +27,10 @@ const WalletsPage: React.FC = () => { const [transactions, setTransactions] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [form] = Form.useForm(); + const [batchForm] = Form.useForm(); const [saving, setSaving] = useState(false); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + 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
!event.target.value && setKeyword('')} />仅看欠费 - + + } disabled={selectedRowKeys.length === 0} onClick={openBatchChange}>批量充值/调账 + +
- `共 ${total} 人` }} /> +
`共 ${total} 人` }} + /> setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
@@ -86,6 +128,27 @@ const WalletsPage: React.FC = () => {
+ setBatchModalOpen(false)} + onOk={submitBatchChange} + confirmLoading={saving} + okText="确认批量修改" + > +
+
+ 已选择 {selectedRowKeys.length} 名学生,将按相同金额批量修改水电费余额。 +
+ + + + + + + + +
{ setDrawerOpen(false); setSelected(null); }}>
dayjs(value).format('YYYY-MM-DD HH:mm') }, diff --git a/apps/server/src/wallets/dto/wallet.dto.ts b/apps/server/src/wallets/dto/wallet.dto.ts index a74b176..f15557c 100644 --- a/apps/server/src/wallets/dto/wallet.dto.ts +++ b/apps/server/src/wallets/dto/wallet.dto.ts @@ -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; +} diff --git a/apps/server/src/wallets/wallets.controller.ts b/apps/server/src/wallets/wallets.controller.ts index 6707c52..5e36b24 100644 --- a/apps/server/src/wallets/wallets.controller.ts +++ b/apps/server/src/wallets/wallets.controller.ts @@ -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; + } } diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index f9cd709..489c230 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -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>[] = []; + 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; From 559b8a56e9a63faea88ce851d363413b469161be Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 16 Jul 2026 10:22:23 +0800 Subject: [PATCH 4/4] feat: add attendance device SN classroom bindings --- apps/admin/src/App.tsx | 11 + apps/admin/src/auth/menu-policy.ts | 1 + apps/admin/src/auth/permission-navigation.ts | 1 + apps/admin/src/pages/AttendanceDevices.tsx | 215 +++++++ apps/server/src/app.module.ts | 4 + .../attendance-devices.controller.ts | 85 +++ .../attendance-devices.module.ts | 14 + .../attendance-devices.service.ts | 105 +++ .../dto/attendance-device.dto.ts | 61 ++ .../src/attendance/attendance.module.ts | 4 +- .../src/attendance/attendance.service.ts | 75 ++- .../database/database-migrations.service.ts | 115 +++- .../src/entities/attendance-device.entity.ts | 52 ++ apps/server/src/entities/index.ts | 1 + .../src/integration/dingtalk.service.ts | 4 +- docker/mysql/init.sql | 598 ++++++++++++++++++ 16 files changed, 1323 insertions(+), 23 deletions(-) create mode 100644 apps/admin/src/pages/AttendanceDevices.tsx create mode 100644 apps/server/src/attendance-devices/attendance-devices.controller.ts create mode 100644 apps/server/src/attendance-devices/attendance-devices.module.ts create mode 100644 apps/server/src/attendance-devices/attendance-devices.service.ts create mode 100644 apps/server/src/attendance-devices/dto/attendance-device.dto.ts create mode 100644 apps/server/src/entities/attendance-device.entity.ts create mode 100644 docker/mysql/init.sql diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 59c48e5..b8f6297 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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 = () => { } /> + + + + + } + /> + { + const [data, setData] = useState([]); + const [classrooms, setClassrooms] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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('/attendance-devices'), + api.get('/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 = [ + { title: '设备名称', dataIndex: 'deviceName', width: 180 }, + { title: 'SN 码', dataIndex: 'deviceSn', width: 220, render: (value) => {value} }, + { title: '绑定教室', dataIndex: ['classroom', 'name'], width: 160, render: (_value, record) => record.classroom?.name || `教室 ${record.classroomId}` }, + { title: '位置', dataIndex: 'location', render: (value) => value || }, + { title: '状态', dataIndex: 'status', width: 90, render: (value: keyof typeof statusMeta) => {statusMeta[value]?.text || value} }, + { title: '备注', dataIndex: 'notes', ellipsis: true, render: (value) => value || }, + { + title: '操作', + width: 150, + render: (_, record) => ( + + openEdit(record)}> + 编辑 + + handleDelete(record.id)}> + + 删除 + + + + ), + }, + ]; + + return ( +
+
+ setKeyword(event.target.value)} + /> + } onClick={openCreate}> + 添加考勤机 + +
+ + rowKey="id" + columns={columns} + dataSource={filteredData} + loading={loading} + locale={{ emptyText: }} + pagination={{ defaultPageSize: 20, showSizeChanger: true }} + /> + { + setModalOpen(false); + setEditing(null); + }} + confirmLoading={saving} + okText="保存" + > +
+ + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default AttendanceDevicesPage; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 1fd7bb1..f3f34f1 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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, diff --git a/apps/server/src/attendance-devices/attendance-devices.controller.ts b/apps/server/src/attendance-devices/attendance-devices.controller.ts new file mode 100644 index 0000000..f6c9985 --- /dev/null +++ b/apps/server/src/attendance-devices/attendance-devices.controller.ts @@ -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; + } +} diff --git a/apps/server/src/attendance-devices/attendance-devices.module.ts b/apps/server/src/attendance-devices/attendance-devices.module.ts new file mode 100644 index 0000000..97acc21 --- /dev/null +++ b/apps/server/src/attendance-devices/attendance-devices.module.ts @@ -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 {} diff --git a/apps/server/src/attendance-devices/attendance-devices.service.ts b/apps/server/src/attendance-devices/attendance-devices.service.ts new file mode 100644 index 0000000..d498ec2 --- /dev/null +++ b/apps/server/src/attendance-devices/attendance-devices.service.ts @@ -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, + @InjectRepository(Classroom) + private readonly classroomRepo: Repository, + ) {} + + private normalizeSn(sn: string): string { + return sn.trim(); + } + + private async assertClassroomExists(classroomId: number): Promise { + 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 = {}; + 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 = { ...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(); + 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(); + const devices = await this.repo.find({ + where: { classroomId: In(ids), status: AttendanceDeviceStatus.ACTIVE }, + relations: ['classroom'], + order: { id: 'ASC' }, + }); + const result = new Map(); + for (const device of devices) { + if (!result.has(device.classroomId)) result.set(device.classroomId, device); + } + return result; + } +} diff --git a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts new file mode 100644 index 0000000..e773104 --- /dev/null +++ b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts @@ -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; +} diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index c411d68..8a341b0 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -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, ], diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 859893e..98e5a00 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -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, @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, + @InjectRepository(AttendanceDevice) + private attendanceDeviceRepo: Repository, 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( + records: T[], + classroomId?: number | null, + ): Promise { + if (records.length === 0) return records; + const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])]; + const devicesBySn = new Map(); + 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(); + 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 { 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) }; }), ); } diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 372e45f..0d9708a 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { 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 { + 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 { const runner = this.dataSource.createQueryRunner(); await runner.connect(); @@ -456,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { private async normalizeClassDates(): Promise { 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} `), ); diff --git a/apps/server/src/entities/attendance-device.entity.ts b/apps/server/src/entities/attendance-device.entity.ts new file mode 100644 index 0000000..1391fba --- /dev/null +++ b/apps/server/src/entities/attendance-device.entity.ts @@ -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; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 32d021a..8d21c6a 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -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'; diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 8e12afe..10fe8ff 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -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, })); } diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 0000000..6ef4389 --- /dev/null +++ b/docker/mysql/init.sql @@ -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;