stud-manager / server.js
dvc890's picture
Upload 44 files
ac9de5d verified
raw
history blame
42.3 kB
// ... 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}`));