const express = require('express'); const router = express.Router(); const { AttendanceModel, LeaveRequestModel, SchoolCalendarModel, WishModel, FeedbackModel, TodoModel, GameSessionModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel, StudentRewardModel, AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, Student, User, ClassModel } = require('../models'); // 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 getGameOwnerFilter = async (req) => { const role = req.headers['x-user-role']; const username = req.headers['x-user-username']; if (role === 'TEACHER') { const user = await User.findOne({ username }); return { ownerId: user ? user._id.toString() : 'unknown' }; } return {}; }; // --- Attendance --- router.get('/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)); }); router.post('/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 }); }); router.post('/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 }); }); router.put('/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 }); }); router.post('/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 }); }); router.get('/attendance/calendar', async (req, res) => { const { className } = req.query; const filter = getQueryFilter(req); const query = { $and: [ filter, { $or: [{ className: { $exists: false } }, { className: null }, { className }] } ] }; res.json(await SchoolCalendarModel.find(query)); }); router.post('/attendance/calendar', async (req, res) => { await SchoolCalendarModel.create(injectSchoolId(req, req.body)); res.json({ success: true }); }); router.delete('/attendance/calendar/:id', async (req, res) => { await SchoolCalendarModel.findByIdAndDelete(req.params.id); res.json({}); }); // --- Wishes --- router.get('/wishes', async (req, res) => { const { teacherId, studentId, status } = req.query; const filter = getQueryFilter(req); if (teacherId) filter.teacherId = teacherId; if (studentId) filter.studentId = studentId; if (status) filter.status = status; res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 })); }); router.post('/wishes', async (req, res) => { const data = injectSchoolId(req, req.body); const existing = await WishModel.findOne({ studentId: data.studentId, status: 'PENDING' }); if (existing) { return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' }); } await WishModel.create(data); res.json({ success: true }); }); router.post('/wishes/:id/fulfill', async (req, res) => { await WishModel.findByIdAndUpdate(req.params.id, { status: 'FULFILLED', fulfillTime: new Date() }); res.json({ success: true }); }); router.post('/wishes/random-fulfill', async (req, res) => { const { teacherId } = req.body; const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' }); if (pendingWishes.length === 0) { return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' }); } const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)]; await WishModel.findByIdAndUpdate(randomWish._id, { status: 'FULFILLED', fulfillTime: new Date() }); res.json({ success: true, wish: randomWish }); }); // --- Feedback --- router.get('/feedback', async (req, res) => { const { targetId, creatorId, type, status } = req.query; const filter = getQueryFilter(req); if (targetId) filter.targetId = targetId; if (creatorId) filter.creatorId = creatorId; if (type) filter.type = type; if (status) { const statuses = status.split(','); if (statuses.length > 1) { filter.status = { $in: statuses }; } else { filter.status = status; } } res.json(await FeedbackModel.find(filter).sort({ createTime: -1 })); }); router.post('/feedback', async (req, res) => { const data = injectSchoolId(req, req.body); await FeedbackModel.create(data); res.json({ success: true }); }); router.put('/feedback/:id', async (req, res) => { const { id } = req.params; const { status, reply } = req.body; const updateData = { updateTime: new Date() }; if (status) updateData.status = status; if (reply !== undefined) updateData.reply = reply; await FeedbackModel.findByIdAndUpdate(id, updateData); res.json({ success: true }); }); router.post('/feedback/ignore-all', async (req, res) => { const { targetId } = req.body; await FeedbackModel.updateMany( { targetId, status: 'PENDING' }, { status: 'IGNORED', updateTime: new Date() } ); res.json({ success: true }); }); // --- Todos --- router.get('/todos', 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(await TodoModel.find({ userId: user._id.toString() }).sort({ isCompleted: 1, createTime: -1 })); }); router.post('/todos', async (req, res) => { const username = req.headers['x-user-username']; const user = await User.findOne({ username }); if (!user) return res.status(404).json({ error: 'User not found' }); await TodoModel.create({ ...req.body, userId: user._id.toString() }); res.json({ success: true }); }); router.put('/todos/:id', async (req, res) => { await TodoModel.findByIdAndUpdate(req.params.id, req.body); res.json({ success: true }); }); router.delete('/todos/:id', async (req, res) => { await TodoModel.findByIdAndDelete(req.params.id); res.json({ success: true }); }); // --- Games & Rewards --- router.get('/games/lucky-config', async (req, res) => { const filter = getQueryFilter(req); if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); } if (req.query.className) filter.className = req.query.className; const config = await LuckyDrawConfigModel.findOne(filter); res.json(config || null); }); router.post('/games/lucky-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await LuckyDrawConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); }); router.post('/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' }); let configFilter = { className: student.className, schoolId }; if (userRole === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); configFilter.ownerId = user ? user._id.toString() : null; } const config = await LuckyDrawConfigModel.findOne(configFilter); 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 } }); } let ownerId = config?.ownerId; await StudentRewardModel.create({ schoolId, studentId, studentName: student.name, rewardType, name: selectedPrize, count: 1, status: 'PENDING', source: '幸运大抽奖', ownerId }); res.json({ prize: selectedPrize, rewardType }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.get('/games/monster-config', async (req, res) => { const filter = getQueryFilter(req); const ownerFilter = await getGameOwnerFilter(req); if (req.query.className) filter.className = req.query.className; Object.assign(filter, ownerFilter); const config = await GameMonsterConfigModel.findOne(filter); res.json(config || {}); }); router.post('/games/monster-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await GameMonsterConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); }); router.get('/games/zen-config', async (req, res) => { const filter = getQueryFilter(req); const ownerFilter = await getGameOwnerFilter(req); if (req.query.className) filter.className = req.query.className; Object.assign(filter, ownerFilter); const config = await GameZenConfigModel.findOne(filter); res.json(config || {}); }); router.post('/games/zen-config', async (req, res) => { const data = injectSchoolId(req, req.body); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } await GameZenConfigModel.findOneAndUpdate({ className: data.className, ...getQueryFilter(req), ownerId: data.ownerId }, data, { upsert: true }); res.json({ success: true }); }); router.get('/games/mountain', async (req, res) => { res.json(await GameSessionModel.findOne({...getQueryFilter(req), className: req.query.className})); }); // Updated mountain game handler with robust permission check router.post('/games/mountain', async (req, res) => { const { className } = req.body; const sId = req.headers['x-school-id']; const role = req.headers['x-user-role']; const username = req.headers['x-user-username']; if (role === 'TEACHER') { const user = await User.findOne({ username }); let isAuthorized = false; // 1. Check User Profile (Primary authority) if (user && user.homeroomClass === className) { isAuthorized = true; } // 2. Check Class Database (Fallback for legacy/ID-linked) if (!isAuthorized) { const cls = await ClassModel.findOne({ schoolId: sId, $or: [ { $expr: { $eq: [{ $concat: ["$grade", "$className"] }, className] } }, { className: className } ] }); if (cls) { const allowedIds = cls.homeroomTeacherIds || []; if (allowedIds.includes(user._id.toString())) { isAuthorized = true; } else if (cls.teacherName && (cls.teacherName.includes(user.trueName) || cls.teacherName.includes(user.username))) { // Legacy Name Match isAuthorized = true; } } else { // If class not found in DB but user has it in profile, we allow (user is creator/authority) // But typically class should exist. We'll stick to logic above. } } if (!isAuthorized) { return res.status(403).json({ error: 'PERMISSION_DENIED', message: '只有班主任可以操作登峰游戏' }); } } await GameSessionModel.findOneAndUpdate({ className, ...getQueryFilter(req) }, injectSchoolId(req, req.body), {upsert:true}); res.json({}); }); router.get('/rewards', async (req, res) => { const filter = getQueryFilter(req); if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); if (user) filter.ownerId = user._id.toString(); } 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 }); }); router.post('/rewards', async (req, res) => { const data = injectSchoolId(req, req.body); if (!data.count) data.count = 1; if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); data.ownerId = user ? user._id.toString() : null; } if(data.rewardType==='DRAW_COUNT') { data.status='REDEEMED'; await Student.findByIdAndUpdate(data.studentId, {$inc:{drawAttempts:data.count}}); } await StudentRewardModel.create(data); res.json({}); }); router.put('/rewards/:id', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, req.body); res.json({}); }); router.delete('/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({}); }); router.post('/rewards/:id/redeem', async (req, res) => { await StudentRewardModel.findByIdAndUpdate(req.params.id, {status:'REDEEMED'}); res.json({}); }); router.post('/games/grant-reward', async (req, res) => { const { studentId, count, rewardType, name } = req.body; const finalCount = count || 1; const finalName = name || (rewardType === 'DRAW_COUNT' ? '抽奖券' : '奖品'); let ownerId = null; if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); ownerId = user ? user._id.toString() : null; } 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: '教师发放', ownerId }); res.json({}); }); // --- Achievements --- router.get('/achievements/config', async (req, res) => { const { className } = req.query; const filter = getQueryFilter(req); if (className) filter.className = className; res.json(await AchievementConfigModel.findOne(filter)); }); router.post('/achievements/config', async (req, res) => { const data = injectSchoolId(req, req.body); await AchievementConfigModel.findOneAndUpdate( { className: data.className, ...getQueryFilter(req) }, data, { upsert: true } ); res.json({ success: true }); }); router.get('/achievements/teacher-rules', async (req, res) => { const filter = getQueryFilter(req); if (req.query.teacherId) { filter.teacherId = req.query.teacherId; } else if (req.headers['x-user-role'] === 'TEACHER') { const user = await User.findOne({ username: req.headers['x-user-username'] }); if (user) filter.teacherId = user._id.toString(); } if (req.query.teacherIds) { const ids = req.query.teacherIds.split(','); delete filter.teacherId; filter.teacherId = { $in: ids }; const configs = await TeacherExchangeConfigModel.find(filter); return res.json(configs); } const config = await TeacherExchangeConfigModel.findOne(filter); res.json(config || { rules: [] }); }); router.post('/achievements/teacher-rules', async (req, res) => { const data = injectSchoolId(req, req.body); const user = await User.findOne({ username: req.headers['x-user-username'] }); if (!user) return res.status(404).json({ error: 'User not found' }); data.teacherId = user._id.toString(); data.teacherName = user.trueName || user.username; await TeacherExchangeConfigModel.findOneAndUpdate( { teacherId: data.teacherId, ...getQueryFilter(req) }, data, { upsert: true } ); res.json({ success: true }); }); // Updated: Supports class-based filtering via student IDs router.get('/achievements/student', async (req, res) => { const { studentId, semester, studentIds } = req.query; let filter = {}; if (studentId) filter.studentId = studentId; if (studentIds) { const ids = studentIds.split(','); filter.studentId = { $in: ids }; } if (semester && semester !== 'ALL') filter.semester = semester; res.json(await StudentAchievementModel.find(filter).sort({ createTime: -1 })); }); // Updated: Saves points snapshot router.post('/achievements/grant', async (req, res) => { const { studentId, achievementId, semester } = req.body; const sId = req.headers['x-school-id']; const student = await Student.findById(studentId); if (!student) return res.status(404).json({ error: 'Student not found' }); const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId }); const achievement = config?.achievements.find(a => a.id === achievementId); if (!achievement) return res.status(404).json({ error: 'Achievement not found' }); await StudentAchievementModel.create({ schoolId: sId, studentId, studentName: student.name, achievementId: achievement.id, achievementName: achievement.name, achievementIcon: achievement.icon, points: achievement.points, // Snapshot points semester, createTime: new Date() }); await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: achievement.points } }); res.json({ success: true }); }); router.delete('/achievements/record/:id', async (req, res) => { try { const record = await StudentAchievementModel.findById(req.params.id); if (!record) return res.status(404).json({ error: 'Record not found' }); const student = await Student.findById(record.studentId); if (student) { // Revoke points: Ensure balance doesn't go below 0 let deduction = record.points || 0; // Fix: If record has 0 points (legacy/bug data), check original achievement config if (deduction === 0 && record.achievementId) { const config = await AchievementConfigModel.findOne({ schoolId: record.schoolId, className: student.className }); if (config) { const achItem = config.achievements.find(a => a.id === record.achievementId); if (achItem) deduction = achItem.points || 0; } } const newBalance = Math.max(0, (student.flowerBalance || 0) - deduction); student.flowerBalance = newBalance; await student.save(); } await StudentAchievementModel.findByIdAndDelete(req.params.id); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.post('/achievements/exchange', async (req, res) => { const { studentId, ruleId, teacherId } = req.body; const sId = req.headers['x-school-id']; const student = await Student.findById(studentId); if (!student) return res.status(404).json({ error: 'Student not found' }); let rule = null; let ownerId = null; if (teacherId) { const tConfig = await TeacherExchangeConfigModel.findOne({ teacherId, schoolId: sId }); rule = tConfig?.rules.find(r => r.id === ruleId); ownerId = teacherId; } else { const config = await AchievementConfigModel.findOne({ className: student.className, schoolId: sId }); rule = config?.exchangeRules?.find(r => r.id === ruleId); } if (!rule) return res.status(404).json({ error: 'Rule not found' }); if (student.flowerBalance < rule.cost) { return res.status(400).json({ error: 'INSUFFICIENT_FUNDS', message: '小红花余额不足' }); } await Student.findByIdAndUpdate(studentId, { $inc: { flowerBalance: -rule.cost } }); await StudentRewardModel.create({ schoolId: sId, studentId, studentName: student.name, rewardType: rule.rewardType, name: rule.rewardName, count: rule.rewardValue, status: rule.rewardType === 'DRAW_COUNT' ? 'REDEEMED' : 'PENDING', source: '积分兑换', ownerId, createTime: new Date() }); if (rule.rewardType === 'DRAW_COUNT') { await Student.findByIdAndUpdate(studentId, { $inc: { drawAttempts: rule.rewardValue } }); } res.json({ success: true }); }); module.exports = router;