stud-manager / routes /features.js
dvc890's picture
Update routes/features.js
38b645f verified
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;