Spaces:
Sleeping
Sleeping
| // ... imports | |
| const express = require('express'); | |
| const mongoose = require('mongoose'); | |
| const cors = require('cors'); | |
| const bodyParser = require('body-parser'); | |
| const path = require('path'); | |
| const compression = require('compression'); | |
| const { | |
| School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel, | |
| ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel, | |
| AchievementConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel | |
| } = require('./models'); | |
| // ... constants | |
| const PORT = 7860; | |
| const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin'; | |
| const app = express(); | |
| // PERFORMANCE 1: Enable Gzip Compression | |
| app.use(compression()); | |
| app.use(cors()); | |
| app.use(bodyParser.json({ limit: '10mb' })); | |
| // PERFORMANCE 2: Smart Caching Strategy | |
| app.use(express.static(path.join(__dirname, 'dist'), { | |
| setHeaders: (res, filePath) => { | |
| if (filePath.endsWith('.html')) { | |
| res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); | |
| } else { | |
| res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); | |
| } | |
| } | |
| })); | |
| const InMemoryDB = { | |
| schools: [], | |
| users: [], | |
| // ... other mock data if needed, but we rely on Mongo mostly now | |
| isFallback: false | |
| }; | |
| const connectDB = async () => { | |
| try { | |
| await mongoose.connect(MONGO_URI, { serverSelectionTimeoutMS: 30000 }); | |
| console.log('✅ MongoDB 连接成功 (Real Data)'); | |
| } catch (err) { | |
| console.error('❌ MongoDB 连接失败:', err.message); | |
| console.warn('⚠️ 启动内存数据库模式 (Limited functionality)'); | |
| InMemoryDB.isFallback = true; | |
| } | |
| }; | |
| connectDB(); | |
| // ... Helpers ... | |
| const getQueryFilter = (req) => { | |
| const s = req.headers['x-school-id']; | |
| const role = req.headers['x-user-role']; | |
| if (role === 'PRINCIPAL') { | |
| if (!s) return { _id: null }; | |
| return { schoolId: s }; | |
| } | |
| if (!s) return {}; | |
| return { | |
| $or: [ | |
| { schoolId: s }, | |
| { schoolId: { $exists: false } }, | |
| { schoolId: null } | |
| ] | |
| }; | |
| }; | |
| const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] }); | |
| const getAutoSemester = () => { | |
| const now = new Date(); | |
| const month = now.getMonth() + 1; // 1-12 | |
| const year = now.getFullYear(); | |
| if (month >= 8 || month === 1) { | |
| const startYear = month === 1 ? year - 1 : year; | |
| return `${startYear}-${startYear + 1}学年 第一学期`; | |
| } else { | |
| const startYear = year - 1; | |
| return `${startYear}-${startYear + 1}学年 第二学期`; | |
| } | |
| }; | |
| const generateStudentNo = async () => { | |
| const year = new Date().getFullYear(); | |
| const random = Math.floor(100000 + Math.random() * 900000); | |
| return `${year}${random}`; | |
| }; | |
| // ... ROUTES ... | |
| app.get('/api/auth/me', async (req, res) => { | |
| const username = req.headers['x-user-username']; | |
| if (!username) return res.status(401).json({ error: 'Unauthorized' }); | |
| const user = await User.findOne({ username }); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| res.json(user); | |
| }); | |
| app.post('/api/auth/update-profile', async (req, res) => { | |
| const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body; | |
| try { | |
| const user = await User.findById(userId); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| // If changing password, verify old one | |
| if (newPassword) { | |
| if (user.password !== currentPassword) { | |
| return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' }); | |
| } | |
| user.password = newPassword; | |
| } | |
| if (trueName) user.trueName = trueName; | |
| if (phone) user.phone = phone; | |
| if (avatar) user.avatar = avatar; | |
| await user.save(); | |
| // If user is a student, sync profile data to Student collection | |
| if (user.role === 'STUDENT') { | |
| await Student.findOneAndUpdate( | |
| { studentNo: user.studentNo }, | |
| { name: user.trueName || user.username, phone: user.phone } | |
| ); | |
| } | |
| // If user is a teacher, sync name to Class collection | |
| if (user.role === 'TEACHER' && user.homeroomClass) { | |
| const cls = await ClassModel.findOne({ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } }); | |
| if (cls) { | |
| cls.teacherName = user.trueName || user.username; | |
| await cls.save(); | |
| } | |
| } | |
| res.json({ success: true, user }); | |
| } catch (e) { | |
| console.error(e); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| app.post('/api/auth/register', async (req, res) => { | |
| const { role, username, password, schoolId, trueName, seatNo } = req.body; | |
| const className = req.body.className || req.body.homeroomClass; | |
| try { | |
| if (role === 'STUDENT') { | |
| if (!trueName || !className) { | |
| return res.status(400).json({ error: 'MISSING_FIELDS', message: '姓名和班级不能为空' }); | |
| } | |
| const cleanName = trueName.trim(); | |
| const cleanClass = className.trim(); | |
| const existingProfile = await Student.findOne({ | |
| schoolId, | |
| name: { $regex: new RegExp(`^${cleanName}$`, 'i') }, | |
| className: cleanClass | |
| }); | |
| let finalUsername = ''; | |
| if (existingProfile) { | |
| if (existingProfile.studentNo && existingProfile.studentNo.length > 5) { | |
| finalUsername = existingProfile.studentNo; | |
| } else { | |
| finalUsername = await generateStudentNo(); | |
| existingProfile.studentNo = finalUsername; | |
| await existingProfile.save(); | |
| } | |
| const userExists = await User.findOne({ username: finalUsername, schoolId }); | |
| if (userExists) { | |
| if (userExists.status === 'active') { | |
| return res.status(409).json({ error: 'ACCOUNT_EXISTS', message: '该学生账号已存在且激活,请直接登录。' }); | |
| } | |
| if (userExists.status === 'pending') { | |
| return res.status(409).json({ error: 'ACCOUNT_PENDING', message: '该学生的注册申请正在审核中,请耐心等待。' }); | |
| } | |
| } | |
| } else { | |
| finalUsername = await generateStudentNo(); | |
| } | |
| await User.create({ | |
| username: finalUsername, | |
| password, | |
| role: 'STUDENT', | |
| trueName: cleanName, | |
| schoolId, | |
| status: 'pending', | |
| homeroomClass: cleanClass, | |
| studentNo: finalUsername, | |
| seatNo: seatNo || '', | |
| parentName: req.body.parentName, | |
| parentPhone: req.body.parentPhone, | |
| address: req.body.address, | |
| idCard: req.body.idCard, | |
| gender: req.body.gender || 'Male', | |
| createTime: new Date() | |
| }); | |
| return res.json({ username: finalUsername }); | |
| } | |
| const existing = await User.findOne({ username }); | |
| if (existing) return res.status(409).json({ error: 'USERNAME_EXISTS', message: '用户名已存在' }); | |
| await User.create({...req.body, status: 'pending', createTime: new Date()}); | |
| res.json({ username }); | |
| } catch(e) { | |
| console.error(e); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| app.get('/api/users', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| const requesterRole = req.headers['x-user-role']; | |
| if (requesterRole === 'PRINCIPAL') filter.role = { $ne: 'ADMIN' }; | |
| if (req.query.role) filter.role = req.query.role; | |
| res.json(await User.find(filter).sort({ createTime: -1 })); | |
| }); | |
| app.put('/api/users/:id', async (req, res) => { | |
| const userId = req.params.id; | |
| const updates = req.body; | |
| const requesterRole = req.headers['x-user-role']; | |
| try { | |
| const user = await User.findById(userId); | |
| if (!user) return res.status(404).json({ error: 'User not found' }); | |
| if (requesterRole === 'PRINCIPAL') { | |
| if (user.schoolId !== req.headers['x-school-id']) return res.status(403).json({ error: 'Permission denied' }); | |
| if (user.role === 'ADMIN' || updates.role === 'ADMIN') return res.status(403).json({ error: 'Cannot modify Admin users' }); | |
| } | |
| if (user.status !== 'active' && updates.status === 'active') { | |
| if (user.role === 'TEACHER' && user.homeroomClass) { | |
| await ClassModel.updateOne( | |
| { schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } }, | |
| { teacherName: user.trueName || user.username } | |
| ); | |
| } | |
| if (user.role === 'STUDENT') { | |
| const profileData = { | |
| schoolId: user.schoolId, | |
| studentNo: user.studentNo, | |
| seatNo: user.seatNo, | |
| name: user.trueName, | |
| className: user.homeroomClass, | |
| gender: user.gender || 'Male', | |
| parentName: user.parentName, | |
| parentPhone: user.parentPhone, | |
| address: user.address, | |
| idCard: user.idCard, | |
| status: 'Enrolled', | |
| birthday: '2015-01-01' | |
| }; | |
| await Student.findOneAndUpdate( | |
| { studentNo: user.studentNo, schoolId: user.schoolId }, | |
| { $set: profileData }, | |
| { upsert: true, new: true } | |
| ); | |
| } | |
| } | |
| await User.findByIdAndUpdate(userId, updates); | |
| res.json({}); | |
| } catch (e) { | |
| console.error(e); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| app.post('/api/users/class-application', async (req, res) => { | |
| const { userId, type, targetClass, action } = req.body; | |
| const userRole = req.headers['x-user-role']; | |
| const schoolId = req.headers['x-school-id']; | |
| if (action === 'APPLY') { | |
| try { | |
| const user = await User.findById(userId); | |
| if(!user) return res.status(404).json({error:'User not found'}); | |
| await User.findByIdAndUpdate(userId, { | |
| classApplication: { type: type, targetClass: targetClass || '', status: 'PENDING' } | |
| }); | |
| const typeText = type === 'CLAIM' ? '申请班主任' : '申请卸任'; | |
| await NotificationModel.create({ | |
| schoolId, targetUserId: userId, title: '申请已提交', content: `您已成功提交 ${typeText} (${type === 'CLAIM' ? targetClass : user.homeroomClass}) 的申请,等待管理员审核。`, type: 'info' | |
| }); | |
| await NotificationModel.create({ schoolId, targetRole: 'ADMIN', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' }); | |
| await NotificationModel.create({ schoolId, targetRole: 'PRINCIPAL', title: '新的班主任任免申请', content: `${user.trueName || user.username} 申请 ${typeText},请及时处理。`, type: 'warning' }); | |
| return res.json({ success: true }); | |
| } catch (e) { | |
| console.error(e); | |
| return res.status(500).json({ error: e.message }); | |
| } | |
| } | |
| if (userRole === 'ADMIN' || userRole === 'PRINCIPAL') { | |
| const user = await User.findById(userId); | |
| if (!user || !user.classApplication) return res.status(404).json({ error: 'Application not found' }); | |
| const appType = user.classApplication.type; | |
| const appTarget = user.classApplication.targetClass; | |
| if (action === 'APPROVE') { | |
| const updates = { classApplication: null }; | |
| if (appType === 'CLAIM') { | |
| updates.homeroomClass = appTarget; | |
| const classes = await ClassModel.find({ schoolId }); | |
| const matchedClass = classes.find(c => (c.grade + c.className) === appTarget); | |
| if (matchedClass) { | |
| if (matchedClass.teacherName) await User.updateOne({ trueName: matchedClass.teacherName, schoolId }, { homeroomClass: '' }); | |
| await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: user.trueName || user.username }); | |
| } | |
| } else if (appType === 'RESIGN') { | |
| updates.homeroomClass = ''; | |
| if (user.homeroomClass) { | |
| const classes = await ClassModel.find({ schoolId }); | |
| const matchedClass = classes.find(c => (c.grade + c.className) === user.homeroomClass); | |
| if (matchedClass) await ClassModel.findByIdAndUpdate(matchedClass._id, { teacherName: '' }); | |
| } | |
| } | |
| await User.findByIdAndUpdate(userId, updates); | |
| await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请已通过', content: `管理员已同意您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'success' }); | |
| } else { | |
| await User.findByIdAndUpdate(userId, { 'classApplication.status': 'REJECTED' }); | |
| await NotificationModel.create({ schoolId, targetUserId: userId, title: '申请被拒绝', content: `管理员拒绝了您的${appType === 'CLAIM' ? '任教' : '卸任'}申请。`, type: 'error' }); | |
| } | |
| return res.json({ success: true }); | |
| } | |
| res.status(403).json({ error: 'Permission denied' }); | |
| }); | |
| app.post('/api/students/promote', async (req, res) => { | |
| const { teacherFollows } = req.body; | |
| const sId = req.headers['x-school-id']; | |
| const role = req.headers['x-user-role']; | |
| if (role !== 'ADMIN' && role !== 'PRINCIPAL') return res.status(403).json({ error: 'Permission denied' }); | |
| const GRADE_MAP = { | |
| '一年级': '二年级', '二年级': '三年级', '三年级': '四年级', '四年级': '五年级', '五年级': '六年级', '六年级': '毕业', | |
| '初一': '初二', '七年级': '八年级', '初二': '初三', '八年级': '九年级', '初三': '毕业', '九年级': '毕业', | |
| '高一': '高二', '高二': '高三', '高三': '毕业' | |
| }; | |
| const classes = await ClassModel.find(getQueryFilter(req)); | |
| let promotedCount = 0; | |
| for (const cls of classes) { | |
| const currentGrade = cls.grade; | |
| const nextGrade = GRADE_MAP[currentGrade] || currentGrade; | |
| const suffix = cls.className; | |
| if (nextGrade === '毕业') { | |
| const oldFullClass = cls.grade + cls.className; | |
| await Student.updateMany({ className: oldFullClass, ...getQueryFilter(req) }, { status: 'Graduated', className: '已毕业' }); | |
| if (teacherFollows && cls.teacherName) { | |
| await User.updateOne({ trueName: cls.teacherName, schoolId: sId }, { homeroomClass: '' }); | |
| await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' }); | |
| } | |
| } else { | |
| const oldFullClass = cls.grade + cls.className; | |
| const newFullClass = nextGrade + suffix; | |
| await ClassModel.findOneAndUpdate({ grade: nextGrade, className: suffix, schoolId: sId }, { schoolId: sId, grade: nextGrade, className: suffix, teacherName: teacherFollows ? cls.teacherName : undefined }, { upsert: true }); | |
| const result = await Student.updateMany({ className: oldFullClass, status: 'Enrolled', ...getQueryFilter(req) }, { className: newFullClass }); | |
| promotedCount += result.modifiedCount; | |
| if (teacherFollows && cls.teacherName) { | |
| await User.updateOne({ trueName: cls.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: newFullClass }); | |
| await ClassModel.findByIdAndUpdate(cls._id, { teacherName: '' }); | |
| } | |
| } | |
| } | |
| res.json({ success: true, count: promotedCount }); | |
| }); | |
| app.post('/api/students/transfer', async (req, res) => { | |
| const { studentId, targetClass } = req.body; | |
| const student = await Student.findById(studentId); | |
| if (!student) return res.status(404).json({ error: 'Student not found' }); | |
| student.className = targetClass; | |
| await student.save(); | |
| res.json({ success: true }); | |
| }); | |
| app.get('/api/achievements/config', async (req, res) => { | |
| const { className } = req.query; | |
| if (!className) return res.status(400).json({ error: 'Class name required' }); | |
| const config = await AchievementConfigModel.findOne({ ...getQueryFilter(req), className }); | |
| res.json(config || { className, achievements: [], exchangeRules: [] }); | |
| }); | |
| app.post('/api/achievements/config', async (req, res) => { | |
| const { className } = req.body; | |
| const sId = req.headers['x-school-id']; | |
| await AchievementConfigModel.findOneAndUpdate({ className, schoolId: sId }, injectSchoolId(req, req.body), { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| app.get('/api/achievements/student', async (req, res) => { | |
| const { studentId, semester } = req.query; | |
| const filter = getQueryFilter(req); | |
| if (studentId) filter.studentId = studentId; | |
| if (semester && semester !== '全部时间' && semester !== 'All') filter.semester = semester; | |
| const list = await StudentAchievementModel.find(filter).sort({ createTime: -1 }); | |
| res.json(list); | |
| }); | |
| app.post('/api/achievements/grant', async (req, res) => { | |
| const { studentId, achievementId, semester } = req.body; | |
| const sId = req.headers['x-school-id']; | |
| const student = await Student.findById(studentId); | |
| const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) }); | |
| const ach = config?.achievements.find(a => a.id === achievementId); | |
| if (!ach) return res.status(404).json({ error: 'Achievement not found' }); | |
| await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: ach.id, achievementName: ach.name, achievementIcon: ach.icon, semester: semester || '当前学期' }); | |
| await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: ach.points } }); | |
| res.json({ success: true }); | |
| }); | |
| app.post('/api/achievements/exchange', async (req, res) => { | |
| const { studentId, ruleId } = req.body; | |
| const sId = req.headers['x-school-id']; | |
| const student = await Student.findById(studentId); | |
| const config = await AchievementConfigModel.findOne({ className: student.className, ...getQueryFilter(req) }); | |
| const rule = config?.exchangeRules.find(r => r.id === ruleId); | |
| if (!rule) return res.status(404).json({ error: 'Exchange rule not found' }); | |
| if ((student.flowerBalance || 0) < rule.cost) return res.status(400).json({ error: 'INSUFFICIENT_FLOWERS', message: '小红花余额不足' }); | |
| await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } }); | |
| let status = 'PENDING'; | |
| if (rule.rewardType === 'DRAW_COUNT') { | |
| status = 'REDEEMED'; | |
| await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } }); | |
| } | |
| await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: status, source: `积分兑换` }); | |
| res.json({ success: true }); | |
| }); | |
| app.get('/api/games/lucky-config', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| if (req.query.className) filter.className = req.query.className; | |
| const config = await LuckyDrawConfigModel.findOne(filter); | |
| res.json(config || { prizes: [], dailyLimit: 3, cardCount: 9, defaultPrize: '再接再厉' }); | |
| }); | |
| app.post('/api/games/lucky-config', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| app.post('/api/games/lucky-draw', async (req, res) => { | |
| const { studentId } = req.body; | |
| const schoolId = req.headers['x-school-id']; | |
| const userRole = req.headers['x-user-role']; | |
| try { | |
| const student = await Student.findById(studentId); | |
| if (!student) return res.status(404).json({ error: 'Student not found' }); | |
| const config = await LuckyDrawConfigModel.findOne({ className: student.className, ...getQueryFilter(req) }); | |
| const prizes = config?.prizes || []; | |
| const defaultPrize = config?.defaultPrize || '再接再厉'; | |
| const dailyLimit = config?.dailyLimit || 3; | |
| const consolationWeight = config?.consolationWeight || 0; | |
| const availablePrizes = prizes.filter(p => (p.count === undefined || p.count > 0)); | |
| if (availablePrizes.length === 0 && consolationWeight === 0) return res.status(400).json({ error: 'POOL_EMPTY', message: '奖品库存不足' }); | |
| if (userRole === 'STUDENT') { | |
| if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '您的抽奖次数已用完' }); | |
| const today = new Date().toISOString().split('T')[0]; | |
| let dailyLog = student.dailyDrawLog || { date: today, count: 0 }; | |
| if (dailyLog.date !== today) dailyLog = { date: today, count: 0 }; | |
| if (dailyLog.count >= dailyLimit) return res.status(403).json({ error: 'DAILY_LIMIT_REACHED', message: `今日抽奖次数已达上限 (${dailyLimit}次)` }); | |
| dailyLog.count += 1; | |
| student.drawAttempts -= 1; | |
| student.dailyDrawLog = dailyLog; | |
| await student.save(); | |
| } else { | |
| if (student.drawAttempts <= 0) return res.status(403).json({ error: '次数不足', message: '该学生抽奖次数已用完' }); | |
| student.drawAttempts -= 1; | |
| await student.save(); | |
| } | |
| let totalWeight = consolationWeight; | |
| availablePrizes.forEach(p => totalWeight += (p.probability || 0)); | |
| let random = Math.random() * totalWeight; | |
| let selectedPrize = defaultPrize; | |
| let rewardType = 'CONSOLATION'; | |
| let matchedPrize = null; | |
| for (const p of availablePrizes) { | |
| random -= (p.probability || 0); | |
| if (random <= 0) { matchedPrize = p; break; } | |
| } | |
| if (matchedPrize) { | |
| selectedPrize = matchedPrize.name; | |
| rewardType = 'ITEM'; | |
| if (config._id) await LuckyDrawConfigModel.updateOne({ _id: config._id, "prizes.id": matchedPrize.id }, { $inc: { "prizes.$.count": -1 } }); | |
| } | |
| await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖' }); | |
| res.json({ prize: selectedPrize, rewardType }); | |
| } catch (e) { res.status(500).json({ error: e.message }); } | |
| }); | |
| app.get('/api/games/monster-config', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| if (req.query.className) filter.className = req.query.className; | |
| const config = await GameMonsterConfigModel.findOne(filter); | |
| res.json(config || {}); | |
| }); | |
| app.post('/api/games/monster-config', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| app.get('/api/games/zen-config', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| if (req.query.className) filter.className = req.query.className; | |
| const config = await GameZenConfigModel.findOne(filter); | |
| res.json(config || {}); | |
| }); | |
| app.post('/api/games/zen-config', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req) }, data, { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| app.get('/api/notifications', async (req, res) => { | |
| const { role, userId } = req.query; | |
| const query = { $and: [ getQueryFilter(req), { $or: [ { targetRole: null, targetUserId: null }, { targetRole: role }, { targetUserId: userId } ] } ] }; | |
| res.json(await NotificationModel.find(query).sort({ createTime: -1 }).limit(20)); | |
| }); | |
| app.get('/api/public/schools', async (req, res) => { res.json(await School.find({}, 'name code _id')); }); | |
| app.get('/api/public/config', async (req, res) => { | |
| const currentSem = getAutoSemester(); | |
| let config = await ConfigModel.findOne({ key: 'main' }); | |
| if (config) { | |
| let semesters = config.semesters || []; | |
| if (!semesters.includes(currentSem)) { | |
| semesters.unshift(currentSem); | |
| config.semesters = semesters; | |
| config.semester = currentSem; | |
| await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); | |
| } | |
| } else { | |
| config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; | |
| } | |
| res.json(config); | |
| }); | |
| app.get('/api/public/meta', async (req, res) => { res.json({ classes: await ClassModel.find({ schoolId: req.query.schoolId }), subjects: await SubjectModel.find({ schoolId: req.query.schoolId }) }); }); | |
| app.post('/api/auth/login', async (req, res) => { | |
| const { username, password } = req.body; | |
| const user = await User.findOne({ username, password }); | |
| if (!user) return res.status(401).json({ message: 'Error' }); | |
| if (user.status !== 'active') return res.status(403).json({ error: 'PENDING_APPROVAL' }); | |
| res.json(user); | |
| }); | |
| app.get('/api/schools', async (req, res) => { res.json(await School.find()); }); | |
| app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); }); | |
| app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/schools/:id', async (req, res) => { | |
| const schoolId = req.params.id; | |
| try { | |
| await School.findByIdAndDelete(schoolId); | |
| await User.deleteMany({ schoolId }); | |
| await Student.deleteMany({ schoolId }); | |
| await ClassModel.deleteMany({ schoolId }); | |
| await SubjectModel.deleteMany({ schoolId }); | |
| await Course.deleteMany({ schoolId }); | |
| await Score.deleteMany({ schoolId }); | |
| await ExamModel.deleteMany({ schoolId }); | |
| await ScheduleModel.deleteMany({ schoolId }); | |
| await NotificationModel.deleteMany({ schoolId }); | |
| await AttendanceModel.deleteMany({ schoolId }); | |
| await LeaveRequestModel.deleteMany({ schoolId }); | |
| await GameSessionModel.deleteMany({ schoolId }); | |
| await StudentRewardModel.deleteMany({ schoolId }); | |
| await LuckyDrawConfigModel.deleteMany({ schoolId }); | |
| await GameMonsterConfigModel.deleteMany({ schoolId }); | |
| await GameZenConfigModel.deleteMany({ schoolId }); | |
| await AchievementConfigModel.deleteMany({ schoolId }); | |
| await StudentAchievementModel.deleteMany({ schoolId }); | |
| await SchoolCalendarModel.deleteMany({ schoolId }); | |
| res.json({ success: true }); | |
| } catch (e) { res.status(500).json({ error: e.message }); } | |
| }); | |
| app.delete('/api/users/:id', async (req, res) => { | |
| const requesterRole = req.headers['x-user-role']; | |
| if (requesterRole === 'PRINCIPAL') { | |
| const user = await User.findById(req.params.id); | |
| if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); | |
| if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); | |
| } | |
| await User.findByIdAndDelete(req.params.id); | |
| res.json({}); | |
| }); | |
| app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); }); | |
| app.post('/api/students', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| if (data.studentNo === '') delete data.studentNo; | |
| try { | |
| const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); | |
| if (existing) { | |
| Object.assign(existing, data); | |
| if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); } | |
| await existing.save(); | |
| } else { | |
| if (!data.studentNo) { data.studentNo = await generateStudentNo(); } | |
| await Student.create(data); | |
| } | |
| res.json({ success: true }); | |
| } catch (e) { | |
| if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); | |
| res.status(500).json({ error: e.message }); | |
| } | |
| }); | |
| app.put('/api/students/:id', async (req, res) => { await Student.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/students/:id', async (req, res) => { await Student.findByIdAndDelete(req.params.id); res.json({}); }); | |
| app.get('/api/classes', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| const cls = await ClassModel.find(filter); | |
| const resData = await Promise.all(cls.map(async c => { | |
| const count = await Student.countDocuments({ className: c.grade + c.className, status: 'Enrolled', ...filter }); | |
| return { ...c.toObject(), studentCount: count }; | |
| })); | |
| res.json(resData); | |
| }); | |
| app.post('/api/classes', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| await ClassModel.create(data); | |
| if (data.teacherName) await User.updateOne({ trueName: data.teacherName, schoolId: data.schoolId }, { homeroomClass: data.grade + data.className }); | |
| res.json({}); | |
| }); | |
| app.put('/api/classes/:id', async (req, res) => { | |
| const classId = req.params.id; | |
| const { grade, className, teacherName } = req.body; | |
| const sId = req.headers['x-school-id']; | |
| const oldClass = await ClassModel.findById(classId); | |
| if (!oldClass) return res.status(404).json({ error: 'Class not found' }); | |
| const newFullClass = grade + className; | |
| const oldFullClass = oldClass.grade + oldClass.className; | |
| if (oldClass.teacherName && (oldClass.teacherName !== teacherName || oldFullClass !== newFullClass)) { | |
| await User.updateOne({ trueName: oldClass.teacherName, schoolId: sId, homeroomClass: oldFullClass }, { homeroomClass: '' }); | |
| } | |
| if (teacherName) await User.updateOne({ trueName: teacherName, schoolId: sId }, { homeroomClass: newFullClass }); | |
| await ClassModel.findByIdAndUpdate(classId, { grade, className, teacherName }); | |
| if (oldFullClass !== newFullClass) await Student.updateMany({ className: oldFullClass, schoolId: sId }, { className: newFullClass }); | |
| res.json({ success: true }); | |
| }); | |
| app.delete('/api/classes/:id', async (req, res) => { | |
| const cls = await ClassModel.findById(req.params.id); | |
| if (cls && cls.teacherName) await User.updateOne({ trueName: cls.teacherName, schoolId: cls.schoolId, homeroomClass: cls.grade + cls.className }, { homeroomClass: '' }); | |
| await ClassModel.findByIdAndDelete(req.params.id); | |
| res.json({}); | |
| }); | |
| app.get('/api/subjects', async (req, res) => { res.json(await SubjectModel.find(getQueryFilter(req))); }); | |
| app.post('/api/subjects', async (req, res) => { await SubjectModel.create(injectSchoolId(req, req.body)); res.json({}); }); | |
| app.put('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/subjects/:id', async (req, res) => { await SubjectModel.findByIdAndDelete(req.params.id); res.json({}); }); | |
| app.get('/api/courses', async (req, res) => { res.json(await Course.find(getQueryFilter(req))); }); | |
| app.post('/api/courses', async (req, res) => { await Course.create(injectSchoolId(req, req.body)); res.json({}); }); | |
| app.put('/api/courses/:id', async (req, res) => { await Course.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/courses/:id', async (req, res) => { await Course.findByIdAndDelete(req.params.id); res.json({}); }); | |
| app.get('/api/scores', async (req, res) => { res.json(await Score.find(getQueryFilter(req))); }); | |
| app.post('/api/scores', async (req, res) => { await Score.create(injectSchoolId(req, req.body)); res.json({}); }); | |
| app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); }); | |
| app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); }); | |
| app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); }); | |
| app.get('/api/schedules', async (req, res) => { | |
| const query = { ...getQueryFilter(req), ...req.query }; | |
| if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; } | |
| res.json(await ScheduleModel.find(query)); | |
| }); | |
| app.post('/api/schedules', async (req, res) => { | |
| const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period }; | |
| const sId = req.headers['x-school-id']; | |
| if(sId) filter.schoolId = sId; | |
| await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true}); | |
| res.json({}); | |
| }); | |
| app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); }); | |
| app.get('/api/stats', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| const studentCount = await Student.countDocuments(filter); | |
| const courseCount = await Course.countDocuments(filter); | |
| const scores = await Score.find({...filter, status: 'Normal'}); | |
| let avgScore = 0; let excellentRate = '0%'; | |
| if (scores.length > 0) { | |
| const total = scores.reduce((sum, s) => sum + s.score, 0); | |
| avgScore = parseFloat((total / scores.length).toFixed(1)); | |
| const excellent = scores.filter(s => s.score >= 90).length; | |
| excellentRate = Math.round((excellent / scores.length) * 100) + '%'; | |
| } | |
| res.json({ studentCount, courseCount, avgScore, excellentRate }); | |
| }); | |
| app.get('/api/config', async (req, res) => { | |
| const currentSem = getAutoSemester(); | |
| let config = await ConfigModel.findOne({key:'main'}); | |
| if (config) { | |
| let semesters = config.semesters || []; | |
| if (!semesters.includes(currentSem)) { | |
| semesters.unshift(currentSem); | |
| config.semesters = semesters; | |
| config.semester = currentSem; | |
| await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); | |
| } | |
| } else { | |
| config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; | |
| } | |
| res.json(config); | |
| }); | |
| app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); }); | |
| app.get('/api/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); }); | |
| app.post('/api/games/mountain', async (req, res) => { | |
| const filter = { className: req.body.className }; | |
| const sId = req.headers['x-school-id']; | |
| if(sId) filter.schoolId = sId; | |
| await GameSessionModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true}); | |
| res.json({}); | |
| }); | |
| app.post('/api/games/grant-reward', async (req, res) => { | |
| const { studentId, count, rewardType, name } = req.body; | |
| const finalCount = count || 1; | |
| const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品'); | |
| if (rewardType === 'DRAW_COUNT') await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: finalCount } }); | |
| await StudentRewardModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: (await Student.findById(studentId)).name, rewardType, name: finalName, count: finalCount, status: rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '教师发放' }); | |
| res.json({}); | |
| }); | |
| app.put('/api/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); | |
| app.delete('/api/rewards/:id', async (req, res) => { | |
| const reward = await StudentRewardModel.findById(req.params.id); | |
| if (!reward) return res.status(404).json({error: 'Not found'}); | |
| if (reward.rewardType === 'DRAW_COUNT') { | |
| const student = await Student.findById(reward.studentId); | |
| if (student && student.drawAttempts < reward.count) return res.status(400).json({ error: 'FAILED_REVOKE', message: '修改失败,次数已被使用' }); | |
| await Student.findByIdAndUpdate(reward.studentId, { $inc: { drawAttempts: -reward.count } }); | |
| } | |
| await StudentRewardModel.findByIdAndDelete(req.params.id); | |
| res.json({}); | |
| }); | |
| app.get('/api/rewards', async (req, res) => { | |
| const filter = getQueryFilter(req); | |
| if(req.query.studentId) filter.studentId = req.query.studentId; | |
| if (req.query.className) { | |
| const classStudents = await Student.find({ className: req.query.className, ...getQueryFilter(req) }, '_id'); | |
| filter.studentId = { $in: classStudents.map(s => s._id.toString()) }; | |
| } | |
| if (req.query.excludeType) filter.rewardType = { $ne: req.query.excludeType }; | |
| const page = parseInt(req.query.page) || 1; | |
| const limit = parseInt(req.query.limit) || 20; | |
| const skip = (page - 1) * limit; | |
| const total = await StudentRewardModel.countDocuments(filter); | |
| const list = await StudentRewardModel.find(filter).sort({createTime:-1}).skip(skip).limit(limit); | |
| res.json({ list, total }); | |
| }); | |
| app.post('/api/rewards', async (req, res) => { | |
| const data = injectSchoolId(req, req.body); | |
| if (!data.count) data.count = 1; | |
| if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); } | |
| await StudentRewardModel.create(data); | |
| res.json({}); | |
| }); | |
| app.post('/api/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); }); | |
| app.get('/api/attendance', async (req, res) => { | |
| const { className, date, studentId } = req.query; | |
| const filter = getQueryFilter(req); | |
| if(className) filter.className = className; | |
| if(date) filter.date = date; | |
| if(studentId) filter.studentId = studentId; | |
| res.json(await AttendanceModel.find(filter)); | |
| }); | |
| app.post('/api/attendance/check-in', async (req, res) => { | |
| const { studentId, date, status } = req.body; | |
| const exists = await AttendanceModel.findOne({ studentId, date }); | |
| if (exists) return res.status(400).json({ error: 'ALREADY_CHECKED_IN', message: '今日已打卡' }); | |
| const student = await Student.findById(studentId); | |
| await AttendanceModel.create({ schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status: status || 'Present', checkInTime: new Date() }); | |
| res.json({ success: true }); | |
| }); | |
| app.post('/api/attendance/batch', async (req, res) => { | |
| const { className, date, status } = req.body; | |
| const students = await Student.find({ className, ...getQueryFilter(req) }); | |
| const ops = students.map(s => ({ updateOne: { filter: { studentId: s._id, date }, update: { $setOnInsert: { schoolId: req.headers['x-school-id'], studentId: s._id, studentName: s.name, className: s.className, date, status: status || 'Present', checkInTime: new Date() } }, upsert: true } })); | |
| if (ops.length > 0) await AttendanceModel.bulkWrite(ops); | |
| res.json({ success: true, count: ops.length }); | |
| }); | |
| app.put('/api/attendance/update', async (req, res) => { | |
| const { studentId, date, status } = req.body; | |
| const student = await Student.findById(studentId); | |
| await AttendanceModel.findOneAndUpdate({ studentId, date }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date, status, checkInTime: new Date() }, { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| app.post('/api/leave', async (req, res) => { | |
| await LeaveRequestModel.create(injectSchoolId(req, req.body)); | |
| const { studentId, startDate } = req.body; | |
| const student = await Student.findById(studentId); | |
| if (student) await AttendanceModel.findOneAndUpdate({ studentId, date: startDate }, { schoolId: req.headers['x-school-id'], studentId, studentName: student.name, className: student.className, date: startDate, status: 'Leave', checkInTime: new Date() }, { upsert: true }); | |
| res.json({ success: true }); | |
| }); | |
| // CALENDAR ROUTES | |
| app.get('/api/attendance/calendar', async (req, res) => { | |
| const { className } = req.query; | |
| const filter = getQueryFilter(req); | |
| // Return both global holidays (className undefined or null) and class specific ones | |
| const query = { | |
| $and: [ | |
| filter, | |
| { $or: [{ className: { $exists: false } }, { className: null }, { className }] } | |
| ] | |
| }; | |
| res.json(await SchoolCalendarModel.find(query)); | |
| }); | |
| app.post('/api/attendance/calendar', async (req, res) => { | |
| await SchoolCalendarModel.create(injectSchoolId(req, req.body)); | |
| res.json({ success: true }); | |
| }); | |
| app.delete('/api/attendance/calendar/:id', async (req, res) => { | |
| await SchoolCalendarModel.findByIdAndDelete(req.params.id); | |
| res.json({ success: true }); | |
| }); | |
| app.post('/api/batch-delete', async (req, res) => { | |
| if(req.body.type==='student') await Student.deleteMany({_id:{$in:req.body.ids}}); | |
| if(req.body.type==='score') await Score.deleteMany({_id:{$in:req.body.ids}}); | |
| res.json({}); | |
| }); | |
| app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); | |
| app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`)); |