Migrate project from typing_practiceweb
This commit is contained in:
106
server/routes/admin.controller.ts
Normal file
106
server/routes/admin.controller.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { OAuth2Client } from '../models/oauth2';
|
||||
import { generateRandomString } from '../utils/crypto';
|
||||
|
||||
export class AdminController {
|
||||
// 创建OAuth2客户端
|
||||
async createOAuth2Client(req: Request, res: Response) {
|
||||
const { name, redirectUris, scope } = req.body;
|
||||
|
||||
try {
|
||||
const clientId = generateRandomString(24);
|
||||
const clientSecret = generateRandomString(32);
|
||||
|
||||
const client = new OAuth2Client({
|
||||
name,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUris,
|
||||
scope: scope.split(' '),
|
||||
grants: ['authorization_code']
|
||||
});
|
||||
|
||||
await client.save();
|
||||
|
||||
res.json({
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectUris,
|
||||
scope
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error1' });
|
||||
}
|
||||
}
|
||||
|
||||
// 获取OAuth2客户端列表
|
||||
async listOAuth2Clients(req: Request, res: Response) {
|
||||
try {
|
||||
const clients = await OAuth2Client.find({}, {
|
||||
clientSecret: 0
|
||||
});
|
||||
res.json(clients);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error2' });
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个OAuth2客户端
|
||||
async getOAuth2Client(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const client = await OAuth2Client.findOne({ clientId: id }, {
|
||||
clientSecret: 0
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: 'client_not_found' });
|
||||
}
|
||||
|
||||
res.json(client);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error9' });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新OAuth2客户端
|
||||
async updateOAuth2Client(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, redirectUris, scope } = req.body;
|
||||
|
||||
const client = await OAuth2Client.findOne({ clientId: id });
|
||||
if (!client) {
|
||||
return res.status(404).json({ error: 'client_not_found' });
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
name,
|
||||
redirectUris,
|
||||
scope: scope.split(' ')
|
||||
};
|
||||
|
||||
await OAuth2Client.updateOne({ clientId: id }, updateData);
|
||||
res.json({ ...updateData, clientId: id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error3' });
|
||||
}
|
||||
}
|
||||
|
||||
// 删除OAuth2客户端
|
||||
async deleteOAuth2Client(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await OAuth2Client.deleteOne({ clientId: id });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({ error: 'client_not_found' });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error4' });
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server/routes/admin.routes.ts
Normal file
14
server/routes/admin.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
const router = Router();
|
||||
const adminController = new AdminController();
|
||||
|
||||
// OAuth2客户端管理路由
|
||||
router.post('/oauth2/clients', adminController.createOAuth2Client);
|
||||
router.get('/oauth2/clients', adminController.listOAuth2Clients);
|
||||
router.get('/oauth2/clients/:id', adminController.getOAuth2Client);
|
||||
router.put('/oauth2/clients/:id', adminController.updateOAuth2Client);
|
||||
router.delete('/oauth2/clients/:id', adminController.deleteOAuth2Client);
|
||||
|
||||
export default router;
|
||||
324
server/routes/admin.ts
Normal file
324
server/routes/admin.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// server/routes/admin.ts
|
||||
import express from 'express';
|
||||
import { adminAuth } from '../middleware/auth';
|
||||
import { User } from '../models/User';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { OAuth2Client } from '../models/oauth2';
|
||||
import { AdminController } from './admin.controller';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import csv from 'csv-parser';
|
||||
import { Word, WordSet } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const router = express.Router();
|
||||
const adminController = new AdminController();
|
||||
|
||||
// 配置 multer 用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
// 确保上传目录存在
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `${Date.now()}-${file.originalname}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 限制5MB
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
// 只允许上传CSV文件
|
||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只支持CSV文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const users = await User.find()
|
||||
.select('-password')
|
||||
.sort({ createdAt: -1 });
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: '获取用户列表失败' });
|
||||
}
|
||||
});
|
||||
// 添加 PUT 路由用于更新用户
|
||||
router.put('/users/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
// 检查用户名是否已存在(排除当前用户)
|
||||
const existingUser = await User.findOne({
|
||||
username: req.body.username,
|
||||
_id: { $ne: req.params.id }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: '用户名已被使用' });
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在(排除当前用户)
|
||||
if (req.body.email) {
|
||||
const existingEmail = await User.findOne({
|
||||
email: req.body.email,
|
||||
_id: { $ne: req.params.id }
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ message: '邮箱已被使用' });
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
req.body,
|
||||
{ new: true, select: '-password' }
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
res.status(500).json({ message: '更新用户失败' });
|
||||
}
|
||||
});
|
||||
router.post('/users', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { username, email, password, fullname, isAdmin } = req.body;
|
||||
// 验证必填字段
|
||||
if (!username || !email || !password || !fullname) {
|
||||
return res.status(400).json({ message: '请填写所有必填字段' });
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: '用户名已被使用' });
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
const existingEmail = await User.findOne({ email });
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ message: '邮箱已被使用' });
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
|
||||
const newUser = new User({
|
||||
username,
|
||||
email,
|
||||
password: password,
|
||||
fullname,
|
||||
isAdmin: isAdmin || false
|
||||
});
|
||||
|
||||
await newUser.save();
|
||||
|
||||
// 返回用户信息(不包含密码)
|
||||
const userResponse = await User.findById(newUser._id).select('-password');
|
||||
res.status(201).json(userResponse);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
res.status(500).json({ message: '创建用户失败' });
|
||||
}
|
||||
});
|
||||
// 添加删除用户路由
|
||||
router.delete('/users/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
// 防止删除最后一个管理员
|
||||
if (user.isAdmin) {
|
||||
const adminCount = await User.countDocuments({ isAdmin: true });
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ message: '不能删除最后一个管理员' });
|
||||
}
|
||||
}
|
||||
|
||||
await User.findByIdAndDelete(req.params.id);
|
||||
res.json({ message: '用户已删除' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({ message: '删除用户失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth2 客户端管理路由
|
||||
router.get('/oauth2/clients', adminAuth, (req, res) => adminController.listOAuth2Clients(req, res));
|
||||
|
||||
router.post('/oauth2/clients', adminAuth, (req, res) => adminController.createOAuth2Client(req, res));
|
||||
|
||||
router.put('/oauth2/clients/:id', adminAuth, (req, res) => adminController.updateOAuth2Client(req, res));
|
||||
|
||||
router.delete('/oauth2/clients/:id', adminAuth, (req, res) => adminController.deleteOAuth2Client(req, res));
|
||||
|
||||
// ===== 单词库管理路由 =====
|
||||
|
||||
// 获取所有单词集
|
||||
router.get('/vocabulary/word-sets', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const wordSets = await WordSet.find()
|
||||
.select('name description totalWords createdAt owner')
|
||||
.populate('owner', 'username fullname')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
res.json(wordSets);
|
||||
} catch (error) {
|
||||
console.error('获取单词集失败:', error);
|
||||
res.status(500).json({ message: '获取单词集失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传单词文件并创建单词集(管理员版本)
|
||||
router.post('/vocabulary/upload', adminAuth, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: '请上传文件' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { name, description, ownerId } = req.body;
|
||||
const fileName = req.file.filename;
|
||||
const filePath = req.file.path;
|
||||
|
||||
// 如果没有指定所有者,默认为当前管理员
|
||||
const owner = ownerId || req.user._id;
|
||||
|
||||
// 检查是否已存在同名单词集
|
||||
const existingSet = await WordSet.findOne({
|
||||
name: name || path.basename(fileName, '.csv'),
|
||||
owner: owner
|
||||
});
|
||||
|
||||
if (existingSet) {
|
||||
// 删除上传的文件
|
||||
fs.unlinkSync(filePath);
|
||||
return res.status(400).json({ message: '已存在同名单词集' });
|
||||
}
|
||||
|
||||
// 创建单词集
|
||||
const wordSet = await WordSet.create({
|
||||
name: name || path.basename(fileName, '.csv'),
|
||||
description: description || '',
|
||||
owner: owner,
|
||||
totalWords: 0
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
let wordCount = 0;
|
||||
|
||||
// 处理CSV文件
|
||||
fs.createReadStream(filePath)
|
||||
.pipe(csv())
|
||||
.on('data', (data) => {
|
||||
// 检查必要的字段是否存在
|
||||
if (data.word && data.translation) {
|
||||
results.push({
|
||||
word: data.word.trim(),
|
||||
translation: data.translation.trim(),
|
||||
pronunciation: data.pronunciation?.trim(),
|
||||
example: data.example?.trim(),
|
||||
wordSet: wordSet._id
|
||||
});
|
||||
wordCount++;
|
||||
}
|
||||
})
|
||||
.on('end', async () => {
|
||||
try {
|
||||
// 批量插入单词
|
||||
if (results.length > 0) {
|
||||
await Word.insertMany(results);
|
||||
|
||||
// 更新单词集的单词数量
|
||||
await WordSet.findByIdAndUpdate(wordSet._id, { totalWords: wordCount });
|
||||
}
|
||||
|
||||
// 删除上传的文件
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.status(201).json({
|
||||
message: `成功创建单词集并导入${wordCount}个单词`,
|
||||
wordSet
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果插入单词失败,删除创建的单词集
|
||||
await WordSet.findByIdAndDelete(wordSet._id);
|
||||
|
||||
console.error('导入单词失败:', error);
|
||||
res.status(500).json({ message: '导入单词失败' });
|
||||
}
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.error('处理CSV文件失败:', error);
|
||||
res.status(500).json({ message: '处理CSV文件失败' });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传文件失败:', error);
|
||||
res.status(500).json({ message: '上传文件失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除单词集
|
||||
router.delete('/vocabulary/word-sets/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 检查单词集是否存在
|
||||
const wordSet = await WordSet.findById(id);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
// 删除单词集及其关联的所有单词
|
||||
await Word.deleteMany({ wordSet: id });
|
||||
await WordSet.findByIdAndDelete(id);
|
||||
|
||||
res.json({ message: '单词集已删除' });
|
||||
} catch (error) {
|
||||
console.error('删除单词集失败:', error);
|
||||
res.status(500).json({ message: '删除单词集失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 单词集详情(包括所有单词)
|
||||
router.get('/vocabulary/word-sets/:id/words', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 检查单词集是否存在
|
||||
const wordSet = await WordSet.findById(id);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
// 获取单词集中的所有单词
|
||||
const words = await Word.find({ wordSet: id });
|
||||
|
||||
res.json({
|
||||
wordSet,
|
||||
words
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取单词集详情失败:', error);
|
||||
res.status(500).json({ message: '获取单词集详情失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
296
server/routes/auth.ts
Normal file
296
server/routes/auth.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
import { MongoError } from 'mongodb';
|
||||
import { User, type IUser, type UserStats } from '../models/User';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { Session } from 'express-session';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
interface CustomSession extends Session {
|
||||
userId?: string;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
// 扩展 Request 类型
|
||||
interface CustomRequest extends Request {
|
||||
session: CustomSession;
|
||||
}
|
||||
|
||||
// 定义 JWT payload 的类型
|
||||
interface UserPayload {
|
||||
_id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// 扩展 Express 的 Request 类型
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 定义路由处理函数类型
|
||||
type RouteHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<void | any>; // 允许任何返回值
|
||||
|
||||
// 登录处理函数
|
||||
const loginHandler: RouteHandler = async (req: CustomRequest, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
console.log('Login attempt:', { username });
|
||||
|
||||
const user = await User.findOne({ username });
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
res.status(401).json({ message: '用户名不存在' });
|
||||
return;
|
||||
}
|
||||
const isPasswordValid = await user.comparePassword(password);
|
||||
if (!isPasswordValid) {
|
||||
console.log('Password mismatch');
|
||||
return res.status(401).json({ message: '密码错误' });
|
||||
}
|
||||
|
||||
// 确保设置这些session值
|
||||
req.session.userId = (user._id as mongoose.Types.ObjectId).toString();
|
||||
req.session.isAuthenticated = true;
|
||||
|
||||
// 如果有重定向参数,则重定向回原始OAuth2请求
|
||||
const redirectUrl = req.query.redirect || '/';
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
fullname: user.fullname,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin
|
||||
},
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// 调试:打印 token 信息(不打印完整 token)
|
||||
console.log('Generated token info:', {
|
||||
username: user.username,
|
||||
tokenLength: token.length,
|
||||
tokenStart: token.substring(0, 20) + '...'
|
||||
});
|
||||
|
||||
console.log('Login successful:', {
|
||||
username, fullname:user.fullname,
|
||||
isAdmin: user.isAdmin,
|
||||
hasToken: !!token
|
||||
});
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
fullname: user.fullname,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin
|
||||
},
|
||||
success: true,
|
||||
redirectUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ message: '登录失败' });
|
||||
}
|
||||
};
|
||||
|
||||
// 注册处理函数
|
||||
const registerHandler: RouteHandler = async (req, res) => {
|
||||
try {
|
||||
const { username, password, email,fullname } = req.body;
|
||||
|
||||
console.log('Registration attempt:', { username, email,fullname });
|
||||
if (!email) {
|
||||
return res.status(400).json({ message: '邮箱是必填项' });
|
||||
}
|
||||
// 检查用户名是否存在
|
||||
const existingUsername = await User.findOne({ username });
|
||||
if (existingUsername) {
|
||||
return res.status(400).json({ message: '用户名已存在' });
|
||||
}
|
||||
|
||||
|
||||
const existingEmail = await User.findOne({ email });
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ message: '邮箱已被使用' });
|
||||
}
|
||||
|
||||
const userCount = await User.countDocuments();
|
||||
const isAdmin = userCount === 0;
|
||||
|
||||
const user = new User({
|
||||
username,
|
||||
password,
|
||||
email,fullname ,
|
||||
isAdmin
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
fullname: user.fullname,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin
|
||||
},
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
console.log('Registration successful:', {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
hasToken: !!token
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
token,
|
||||
user: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
fullname:user.fullname,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
|
||||
if (error instanceof MongoError && error.code === 11000) {
|
||||
res.status(400).json({ message: '用户名或邮箱已存在' });
|
||||
} else {
|
||||
res.status(500).json({
|
||||
message: '注册失败:' + (error instanceof Error ? error.message : '未知错误')
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// 中间件:验证 token
|
||||
const authMiddleware: RouteHandler = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: '未登录' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET) as UserPayload;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ message: '登录已过期,请重新登录' });
|
||||
}
|
||||
};
|
||||
|
||||
// 修改密码处理函数
|
||||
const changePasswordHandler: RouteHandler = async (req, res) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ message: '未登录' });
|
||||
}
|
||||
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
// 参数验证
|
||||
if (!oldPassword || !newPassword) {
|
||||
return res.status(400).json({ message: '请提供原密码和新密码' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ message: '新密码长度至少6个字符' });
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findById(req.user._id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
// 验证原密码
|
||||
const isPasswordValid = await user.comparePassword(oldPassword);
|
||||
if (!isPasswordValid) {
|
||||
console.log('Old password mismatch for user:', user.username);
|
||||
return res.status(401).json({ message: '原密码错误' });
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
user.password = newPassword;
|
||||
await user.save();
|
||||
|
||||
console.log('Password changed successfully for user:', user.username);
|
||||
res.json({ message: '密码修改成功' });
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({
|
||||
message: '修改密码失败:' + (error instanceof Error ? error.message : '未知错误')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 登出处理函数
|
||||
const logoutHandler: RouteHandler = async (req: CustomRequest, res) => {
|
||||
try {
|
||||
console.log('Logout request received');
|
||||
|
||||
// 清除 session
|
||||
if (req.session) {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Session destruction error:', err);
|
||||
return res.status(500).json({ message: '登出失败' });
|
||||
}
|
||||
|
||||
// 清除 cookie
|
||||
res.clearCookie('connect.sid');
|
||||
|
||||
console.log('Logout successful');
|
||||
res.json({ success: true, message: '登出成功' });
|
||||
});
|
||||
} else {
|
||||
console.log('No session to destroy');
|
||||
res.json({ success: true, message: '登出成功' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ message: '登出失败' });
|
||||
}
|
||||
};
|
||||
|
||||
// 登录路由
|
||||
router.post('/login', loginHandler);
|
||||
|
||||
// 注册路由
|
||||
router.post('/register', registerHandler);
|
||||
|
||||
// 登出路由
|
||||
router.post('/logout', logoutHandler);
|
||||
|
||||
// 修改密码路由
|
||||
router.post('/change-password', authMiddleware, changePasswordHandler);
|
||||
|
||||
export default router;
|
||||
80
server/routes/codeExamples.ts
Normal file
80
server/routes/codeExamples.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import express from 'express';
|
||||
import { CodeExample } from '../models/CodeExample';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取所有代码示例
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const examples = await CodeExample.find().sort({ createdAt: -1 });
|
||||
res.json(examples);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching code examples' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取特定难度级别的代码示例
|
||||
router.get('/:level', async (req, res) => {
|
||||
try {
|
||||
const { level } = req.params;
|
||||
// 验证难度级别是否有效
|
||||
const validLevels = ['basic', 'intermediate', 'advanced'];
|
||||
if (!validLevels.includes(level)) {
|
||||
return res.status(400).json({ message: '无效的代码难度级别' });
|
||||
}
|
||||
|
||||
// 查找匹配难度级别的代码示例
|
||||
const example = await CodeExample.findOne({ level }).sort({ createdAt: -1 });
|
||||
|
||||
if (!example) {
|
||||
return res.status(404).json({ message: '未找到该难度级别的代码示例' });
|
||||
}
|
||||
|
||||
// 返回代码示例内容
|
||||
res.json({
|
||||
content: example.content,
|
||||
level: example.level,
|
||||
title: example.title
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取代码示例错误:', error);
|
||||
res.status(500).json({ message: '获取代码示例失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建新代码示例
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const example = new CodeExample(req.body);
|
||||
await example.save();
|
||||
res.status(201).json(example);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error creating code example' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新代码示例
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const example = await CodeExample.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
req.body,
|
||||
{ new: true }
|
||||
);
|
||||
res.json(example);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error updating code example' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除代码示例
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await CodeExample.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error deleting code example' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
65
server/routes/keywords.ts
Normal file
65
server/routes/keywords.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import express from 'express';
|
||||
import { CodeExample } from '../models/CodeExample'; // 改用 CodeExample 模型
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取关键字
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// 从 CodeExample 集合中查询 level 为 'keyword' 的数据
|
||||
const keywordExample = await CodeExample.findOne({ level: 'keyword' });
|
||||
|
||||
if (!keywordExample) {
|
||||
return res.status(404).json({ error: '未找到关键字数据' });
|
||||
}
|
||||
|
||||
res.json({ content: keywordExample.content });
|
||||
} catch (error) {
|
||||
console.error('获取关键字失败:', error);
|
||||
res.status(500).json({ error: '获取关键字失败' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 添加创建/更新关键字的路由
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (!content) {
|
||||
return res.status(400).json({ error: '关键字内容不能为空' });
|
||||
}
|
||||
|
||||
const keywords = await CodeExample.create({
|
||||
content,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
res.status(201).json(keywords);
|
||||
} catch (error) {
|
||||
console.error('创建关键字失败:', error);
|
||||
res.status(500).json({ error: '创建关键字失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新关键字
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
if (!content) {
|
||||
return res.status(400).json({ error: '关键字内容不能为空' });
|
||||
}
|
||||
|
||||
const keywords = await CodeExample.findOneAndUpdate(
|
||||
{},
|
||||
{ content, updatedAt: new Date() },
|
||||
{ new: true, upsert: true }
|
||||
);
|
||||
|
||||
res.json(keywords);
|
||||
} catch (error) {
|
||||
console.error('更新关键字失败:', error);
|
||||
res.status(500).json({ error: '更新关键字失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
146
server/routes/leaderboard.ts
Normal file
146
server/routes/leaderboard.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// server/routes/leaderboard.ts
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PracticeRecord } from '../models/PracticeRecord';
|
||||
import { auth } from '../middleware/auth';
|
||||
|
||||
// 定义排序字段类型
|
||||
type SortField = 'totalWords' | 'accuracy' | 'duration' | 'speed';
|
||||
|
||||
// 排序字段映射
|
||||
const sortFieldMapping = {
|
||||
totalWords: { field: '$totalWords', label: '总单词数' },
|
||||
accuracy: { field: '$avgAccuracy', label: '平均正确率' },
|
||||
duration: { field: '$totalDuration', label: '练习总时长' },
|
||||
speed: { field: '$avgSpeed', label: '平均速度' }
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取排行榜数据
|
||||
router.get('/:type', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const {
|
||||
page = '1',
|
||||
limit = '10',
|
||||
sortBy = 'totalWords' // 默认按总单词数排序
|
||||
} = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const pageSize = parseInt(limit as string);
|
||||
const skipCount = (pageNum - 1) * pageSize;
|
||||
const sortField = sortBy as SortField;
|
||||
|
||||
if (!Object.keys(sortFieldMapping).includes(sortField)) {
|
||||
return res.status(400).json({ error: '无效的排序字段' });
|
||||
}
|
||||
|
||||
// 聚合查询
|
||||
const records = await PracticeRecord.aggregate([
|
||||
{ $match: { type } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
username: { $first: '$username' },
|
||||
fullname: { $first: '$fullname' },
|
||||
totalWords: { $sum: '$stats.totalWords' },
|
||||
avgAccuracy: { $avg: '$stats.accuracy' },
|
||||
totalDuration: { $sum: '$stats.duration' },
|
||||
avgSpeed: { $avg: '$stats.wordsPerMinute' },
|
||||
lastPractice: { $max: '$stats.endTime' },
|
||||
practiceCount: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
[sortFieldMapping[sortField].field.substring(1)]: -1
|
||||
}
|
||||
},
|
||||
{ $skip: skipCount },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$project: {
|
||||
userId: '$_id',
|
||||
username: 1,
|
||||
fullname: 1,
|
||||
stats: {
|
||||
totalWords: '$totalWords',
|
||||
accuracy: '$avgAccuracy',
|
||||
duration: '$totalDuration',
|
||||
wordsPerMinute: '$avgSpeed',
|
||||
practiceCount: '$practiceCount',
|
||||
lastPractice: '$lastPractice'
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// 获取总用户数
|
||||
const totalUsers = await PracticeRecord.distinct('userId', { type });
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: totalUsers.length,
|
||||
currentPage: pageNum,
|
||||
totalPages: Math.ceil(totalUsers.length / pageSize),
|
||||
sortField,
|
||||
sortLabel: sortFieldMapping[sortField].label
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({ error: '获取排行榜失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取用户排名
|
||||
router.get('/:type/my-rank', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const { sortBy = 'totalWords' } = req.query;
|
||||
const sortField = sortBy as SortField;
|
||||
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
if (!Object.keys(sortFieldMapping).includes(sortField)) {
|
||||
return res.status(400).json({ error: '无效的排序字段' });
|
||||
}
|
||||
|
||||
// 获取所有用户的排序后记录
|
||||
const allRecords = await PracticeRecord.aggregate([
|
||||
{ $match: { type } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
totalWords: { $sum: '$stats.totalWords' },
|
||||
avgAccuracy: { $avg: '$stats.accuracy' },
|
||||
totalDuration: { $sum: '$stats.duration' },
|
||||
avgSpeed: { $avg: '$stats.wordsPerMinute' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
[sortFieldMapping[sortField].field.substring(1)]: -1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// 找到用户的排名
|
||||
const userRank = allRecords.findIndex(record =>
|
||||
record._id.toString() === req.user!._id
|
||||
) + 1;
|
||||
|
||||
res.json({
|
||||
rank: userRank > 0 ? userRank : null,
|
||||
totalParticipants: allRecords.length,
|
||||
sortField,
|
||||
sortLabel: sortFieldMapping[sortField].label
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排名失败:', error);
|
||||
res.status(500).json({ error: '获取排名失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
163
server/routes/minesweeper.ts
Normal file
163
server/routes/minesweeper.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// server/routes/minesweeper.ts
|
||||
import express, { Request, Response } from 'express';
|
||||
import { MinesweeperRecord, MinesweeperDifficulty } from '../models/MinesweeperRecord';
|
||||
import { auth } from '../middleware/auth';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 提交游戏记录(需要登录)
|
||||
router.post('/record', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { difficulty, timeSeconds, won } = req.body;
|
||||
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
// 验证难度级别
|
||||
const validDifficulties: MinesweeperDifficulty[] = ['beginner', 'intermediate', 'expert', 'brutal'];
|
||||
if (!validDifficulties.includes(difficulty)) {
|
||||
return res.status(400).json({ error: '无效的难度级别' });
|
||||
}
|
||||
|
||||
// 验证时间
|
||||
if (typeof timeSeconds !== 'number' || timeSeconds < 0) {
|
||||
return res.status(400).json({ error: '无效的时间' });
|
||||
}
|
||||
|
||||
// 验证胜负
|
||||
if (typeof won !== 'boolean') {
|
||||
return res.status(400).json({ error: '无效的游戏结果' });
|
||||
}
|
||||
|
||||
// 创建游戏记录
|
||||
const record = new MinesweeperRecord({
|
||||
userId: req.user._id,
|
||||
username: req.user.username,
|
||||
fullname: req.user.fullname || req.user.username,
|
||||
difficulty,
|
||||
timeSeconds,
|
||||
won
|
||||
});
|
||||
|
||||
await record.save();
|
||||
|
||||
res.status(201).json({
|
||||
message: '游戏记录保存成功',
|
||||
record
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存游戏记录失败:', error);
|
||||
res.status(500).json({ error: '保存游戏记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取排行榜
|
||||
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { difficulty } = req.params;
|
||||
const { page = '1', limit = '10' } = req.query;
|
||||
|
||||
// 验证难度级别
|
||||
const validDifficulties: MinesweeperDifficulty[] = ['beginner', 'intermediate', 'expert', 'brutal'];
|
||||
if (!validDifficulties.includes(difficulty as MinesweeperDifficulty)) {
|
||||
return res.status(400).json({ error: '无效的难度级别' });
|
||||
}
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const pageSize = parseInt(limit as string);
|
||||
const skipCount = (pageNum - 1) * pageSize;
|
||||
|
||||
// 获取排行榜数据
|
||||
const records = await MinesweeperRecord.getLeaderboard(
|
||||
difficulty as MinesweeperDifficulty,
|
||||
skipCount,
|
||||
pageSize
|
||||
);
|
||||
|
||||
// 获取总用户数(只统计获胜的用户)
|
||||
const totalUsers = await MinesweeperRecord.distinct('userId', {
|
||||
difficulty,
|
||||
won: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: totalUsers.length,
|
||||
currentPage: pageNum,
|
||||
totalPages: Math.ceil(totalUsers.length / pageSize),
|
||||
difficulty
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({ error: '获取排行榜失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取用户的个人最佳成绩(需要登录)
|
||||
router.get('/personal-best/:difficulty', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { difficulty } = req.params;
|
||||
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
// 验证难度级别
|
||||
const validDifficulties: MinesweeperDifficulty[] = ['beginner', 'intermediate', 'expert', 'brutal'];
|
||||
if (!validDifficulties.includes(difficulty as MinesweeperDifficulty)) {
|
||||
return res.status(400).json({ error: '无效的难度级别' });
|
||||
}
|
||||
|
||||
// 查找用户的最佳成绩
|
||||
const bestRecord = await MinesweeperRecord.findOne({
|
||||
userId: req.user._id,
|
||||
difficulty,
|
||||
won: true
|
||||
}).sort({ timeSeconds: 1 });
|
||||
|
||||
if (!bestRecord) {
|
||||
return res.json({
|
||||
hasBest: false,
|
||||
message: '暂无最佳成绩'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
hasBest: true,
|
||||
bestTime: bestRecord.timeSeconds,
|
||||
createdAt: bestRecord.createdAt
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取个人最佳成绩失败:', error);
|
||||
res.status(500).json({ error: '获取个人最佳成绩失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取用户的游戏统计(需要登录)
|
||||
router.get('/stats', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const stats = await MinesweeperRecord.aggregate([
|
||||
{ $match: { userId: req.user._id } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$difficulty',
|
||||
totalGames: { $sum: 1 },
|
||||
wonGames: { $sum: { $cond: ['$won', 1, 0] } },
|
||||
bestTime: { $min: { $cond: ['$won', '$timeSeconds', null] } }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
res.json({ stats });
|
||||
} catch (error) {
|
||||
console.error('获取游戏统计失败:', error);
|
||||
res.status(500).json({ error: '获取游戏统计失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
356
server/routes/oauth2.controller.ts
Normal file
356
server/routes/oauth2.controller.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { OAuth2Client, OAuth2AuthorizationCode, OAuth2AccessToken } from '../models/oauth2';
|
||||
import { User } from '../models/User';
|
||||
import { generateRandomString } from '../utils/crypto';
|
||||
import { Session } from 'express-session';
|
||||
import fetch from 'node-fetch';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
|
||||
// JWT Payload 类型定义
|
||||
interface UserPayload {
|
||||
_id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
// 添加 session 接口声明
|
||||
interface CustomSession extends Session {
|
||||
userId?: string;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
// 扩展 Request 类型
|
||||
interface CustomRequest extends Request {
|
||||
session: CustomSession;
|
||||
}
|
||||
|
||||
export class OAuth2Controller {
|
||||
// 授权端点
|
||||
async authorize(req: CustomRequest, res: Response) {
|
||||
try {
|
||||
// 调试日志:输出请求信息
|
||||
console.log('--- OAuth2 authorize 调试信息 ---');
|
||||
console.log('req.headers:', req.headers);
|
||||
console.log('req.originalUrl:', req.originalUrl);
|
||||
|
||||
const { client_id, redirect_uri, scope, response_type, state } = req.query;
|
||||
|
||||
// 统一使用 JWT 认证,从多个来源获取 token
|
||||
let token: string | undefined;
|
||||
let currentUser: UserPayload | null = null;
|
||||
|
||||
// 1. 检查 Authorization 头
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
// 2. 检查 cookie(如果存在)
|
||||
if (!token && req.headers.cookie) {
|
||||
const cookies: Record<string, string> = {};
|
||||
req.headers.cookie.split(';').forEach(cookie => {
|
||||
const parts = cookie.trim().split('=');
|
||||
if (parts.length === 2) {
|
||||
cookies[parts[0]] = parts[1];
|
||||
}
|
||||
});
|
||||
|
||||
// 尝试从 cookie 中获取 token
|
||||
if (cookies.token) {
|
||||
token = cookies.token;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 验证 token 并获取用户信息
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
if (typeof decoded === 'object' && decoded !== null && '_id' in decoded) {
|
||||
// 伪造session,让后续逻辑认为已登录
|
||||
req.session.userId = decoded._id as string;
|
||||
req.session.isAuthenticated = true;
|
||||
currentUser = decoded as UserPayload;
|
||||
console.log('JWT验证成功,用户信息:', {
|
||||
userId: currentUser._id,
|
||||
username: currentUser.username
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('JWT验证失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户未登录,重定向到登录页面
|
||||
if (!currentUser) {
|
||||
console.log('用户未登录,重定向到登录页');
|
||||
const redirectPath = '/api' + req.originalUrl;
|
||||
const loginUrl = `/login?redirect=${encodeURIComponent(redirectPath)}`;
|
||||
return res.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// 用户已登录,继续OAuth2授权流程
|
||||
console.log('用户已登录,继续OAuth2授权流程');
|
||||
|
||||
// 从数据库中获取完整用户信息
|
||||
const user = await User.findById(currentUser._id);
|
||||
if (!user) {
|
||||
console.log('数据库中未找到用户:', currentUser._id);
|
||||
return res.status(401).json({ error: 'user_not_found' });
|
||||
}
|
||||
|
||||
// 验证客户端
|
||||
const client = await OAuth2Client.findOne({ clientId: client_id });
|
||||
if (!client) {
|
||||
return res.status(400).json({ error: 'invalid_client' });
|
||||
}
|
||||
|
||||
console.log('OAuth2 authorize start:', {
|
||||
user: user.username,
|
||||
clientId: client_id
|
||||
});
|
||||
|
||||
// 查找现有的用户关联
|
||||
const existingLink = await OAuth2Client.findOne({
|
||||
clientId: client_id,
|
||||
'linkedUsers.username': user.username
|
||||
});
|
||||
|
||||
console.log('OAuth2 link check:', {
|
||||
username: user.username,
|
||||
exists: !!existingLink,
|
||||
linkDetails: existingLink
|
||||
});
|
||||
|
||||
// 只在没有现有关联时创建新的关联
|
||||
if (!existingLink) {
|
||||
console.log('Creating new OAuth link');
|
||||
await OAuth2Client.updateOne(
|
||||
{ clientId: client_id },
|
||||
{
|
||||
$addToSet: {
|
||||
linkedUsers: {
|
||||
userId: user._id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('Using existing OAuth link - proceeding with authorization');
|
||||
}
|
||||
|
||||
// 生成授权码
|
||||
const code = generateRandomString(32);
|
||||
const authCode = new OAuth2AuthorizationCode({
|
||||
code,
|
||||
clientId: client_id,
|
||||
userId: user._id,
|
||||
redirectUri: redirect_uri,
|
||||
scope: (typeof scope === 'string' ? scope.split(' ') : []) || [],
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000)
|
||||
});
|
||||
|
||||
await authCode.save();
|
||||
console.log('Generated auth code:', { code, userId: user._id });
|
||||
|
||||
// 重定向回客户端
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
redirectUrl.searchParams.set('state', state?.toString() || '');
|
||||
|
||||
console.log('Redirecting to:', redirectUrl.toString());
|
||||
return res.redirect(redirectUrl.toString());
|
||||
} catch (error: any) {
|
||||
console.error('OAuth2 authorize error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 令牌端点
|
||||
async token(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('=== OAuth2 Token Debug Info ===');
|
||||
console.log('Token request body:', req.body);
|
||||
|
||||
const { grant_type, code, client_id, client_secret, redirect_uri } = req.body;
|
||||
|
||||
// 验证客户端
|
||||
const client = await OAuth2Client.findOne({
|
||||
clientId: client_id
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
console.log('Client not found:', client_id);
|
||||
return res.status(401).json({ error: 'invalid_client' });
|
||||
}
|
||||
|
||||
if (grant_type === 'authorization_code') {
|
||||
// 查找授权码
|
||||
const authCode = await OAuth2AuthorizationCode.findOne({ code });
|
||||
console.log('Found auth code:', authCode);
|
||||
|
||||
if (!authCode || authCode.expiresAt < new Date()) {
|
||||
console.log('Invalid or expired auth code');
|
||||
return res.status(400).json({ error: 'invalid_grant' });
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findById(authCode.userId);
|
||||
|
||||
// 生成访问令牌
|
||||
const accessToken = generateRandomString(32);
|
||||
const token = new OAuth2AccessToken({
|
||||
accessToken,
|
||||
clientId: client_id,
|
||||
userId: authCode.userId,
|
||||
scope: authCode.scope,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000)
|
||||
});
|
||||
|
||||
await token.save();
|
||||
console.log('Generated access token:', accessToken);
|
||||
|
||||
// 生成 id_token(只要 scope 里有 openid)
|
||||
let id_token;
|
||||
if (authCode.scope.includes('openid')) {
|
||||
const jwt = require('jsonwebtoken');
|
||||
id_token = jwt.sign(
|
||||
{
|
||||
sub: user._id.toString(),
|
||||
name: user.username,
|
||||
fullname: user.fullname,
|
||||
email: user.email,
|
||||
// 可根据需要添加其它 claim
|
||||
},
|
||||
process.env.OPENID_SESSION_SECRET || 'your-secret-keyq', // 用于签名的密钥
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '1h',
|
||||
issuer: process.env.OPENID_ISSUER || 'https://d1kt.cn',
|
||||
audience: client_id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 返回令牌响应
|
||||
return res.json({
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: authCode.scope.join(' '),
|
||||
...(id_token ? { id_token } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type' });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('OAuth2 Token Error:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
body: req.body
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 用户信息端点
|
||||
async userinfo(req: Request, res: Response) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const token = await OAuth2AccessToken.findOne({
|
||||
accessToken,
|
||||
expiresAt: { $gt: new Date() }
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
|
||||
// 查找已关联的用户
|
||||
const linkedUser = await OAuth2Client.findOne({
|
||||
clientId: token.clientId,
|
||||
'linkedUsers.userId': token.userId
|
||||
});
|
||||
|
||||
const user = await User.findById(token.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'user_not_found' });
|
||||
}
|
||||
|
||||
// 根据scope返回用户信息
|
||||
const userInfo: any = {};
|
||||
if (token.scope.includes('openid')) {
|
||||
userInfo.sub = user._id;
|
||||
}
|
||||
if (token.scope.includes('profile')) {
|
||||
userInfo.name = user.username;
|
||||
userInfo.fullname = user.fullname;
|
||||
}
|
||||
if (token.scope.includes('email')) {
|
||||
userInfo.email = user.email;
|
||||
}
|
||||
if (token.scope.includes('firstname')) {
|
||||
userInfo.firstname = user.username;
|
||||
}
|
||||
if (token.scope.includes('lastname')) {
|
||||
userInfo.lastname = user.fullname;
|
||||
}
|
||||
if (token.scope.includes('username')) {
|
||||
userInfo.username = user.username;
|
||||
}
|
||||
console.log('user', user);
|
||||
console.log('userInfo', userInfo);
|
||||
|
||||
console.log('Token scopes:', token.scope);
|
||||
console.log('最终返回的 userInfo:', userInfo);
|
||||
res.json(userInfo);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'server_error6' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMoodleSesskey(req: Request, res: Response) {
|
||||
console.log('Received request for moodle-sesskey'); // 请求开始日志
|
||||
|
||||
try {
|
||||
console.log('Fetching Moodle login page...');
|
||||
const response = await fetch('https://m.d1kt.cn/login/index.php');
|
||||
const html = await response.text();
|
||||
console.log('Moodle response received, length:', html.length); // 检查是否获取到响应
|
||||
|
||||
const sesskeyMatch = html.match(/sesskey":"([^"]+)/);
|
||||
console.log('Sesskey match result:', sesskeyMatch); // 检查正则匹配结果
|
||||
|
||||
const sesskey = sesskeyMatch ? sesskeyMatch[1] : '';
|
||||
|
||||
if (!sesskey) {
|
||||
console.log('No sesskey found in response'); // 未找到 sesskey
|
||||
return res.status(500).json({ error: 'Failed to get sesskey' });
|
||||
}
|
||||
|
||||
console.log('Successfully got sesskey:', sesskey); // 成功获取 sesskey
|
||||
res.json({ sesskey });
|
||||
} catch (error) {
|
||||
console.error('Get sesskey error:', error); // 详细的错误信息
|
||||
res.status(500).json({ error: 'Failed to get sesskey' });
|
||||
}
|
||||
}
|
||||
}
|
||||
16
server/routes/oauth2.routes.ts
Normal file
16
server/routes/oauth2.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { OAuth2Controller } from './oauth2.controller';
|
||||
|
||||
const router = Router();
|
||||
const oauth2Controller = new OAuth2Controller();
|
||||
|
||||
// OAuth2 路由定义
|
||||
router.get('/authorize', oauth2Controller.authorize);
|
||||
router.post('/token', oauth2Controller.token);
|
||||
router.get('/userinfo', oauth2Controller.userinfo);
|
||||
router.get('/moodle-sesskey', (req, res, next) => {
|
||||
console.log('OAuth2 route: /moodle-sesskey accessed');
|
||||
oauth2Controller.getMoodleSesskey(req, res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
325
server/routes/practiceRecords.ts
Normal file
325
server/routes/practiceRecords.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
// server/routes/practiceRecords.ts
|
||||
import express, { Request, Response } from 'express';
|
||||
import { PracticeRecord } from '../models/PracticeRecord';
|
||||
import { auth } from '../middleware/auth';
|
||||
import { Types, Error as MongooseError, startSession } from 'mongoose';
|
||||
import { User, type IUser, type UserStats } from '../models/User';
|
||||
import { validatePracticeSubmission } from '../middleware/practiceValidation';
|
||||
const router = express.Router();
|
||||
|
||||
// 验证练习记录数据
|
||||
interface PracticeStats {
|
||||
totalWords: number;
|
||||
correctWords: number;
|
||||
accuracy: number;
|
||||
wordsPerMinute: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface PracticeRecordBody {
|
||||
type: string;
|
||||
stats: PracticeStats;
|
||||
}
|
||||
|
||||
// 保存练习记录
|
||||
router.post('/', auth, validatePracticeSubmission, async (req: Request, res: Response) => {
|
||||
try {
|
||||
console.log('Received practice record request:', {
|
||||
body: req.body,
|
||||
user: req.user
|
||||
});
|
||||
|
||||
// 验证用户信息
|
||||
if (!req.user ?._id || !req.user?.username) {
|
||||
console.error('Missing user information in request');
|
||||
return res.status(401).json({
|
||||
error: '用户信息无效',
|
||||
code: 'INVALID_USER'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证请求体
|
||||
const { type, stats } = req.body as PracticeRecordBody;
|
||||
if (!type || !stats) {
|
||||
console.error('Invalid request body:', { type, stats });
|
||||
return res.status(400).json({
|
||||
error: '练习记录数据不完整',
|
||||
code: 'INVALID_DATA'
|
||||
});
|
||||
}
|
||||
// 检查是否存在最近的重复提交
|
||||
const recentRecord = await PracticeRecord.findOne({
|
||||
userId: new Types.ObjectId(req.user._id),
|
||||
'stats.startTime': stats.startTime,
|
||||
});
|
||||
|
||||
if (recentRecord) {
|
||||
console.log('Detected duplicate submission:', {
|
||||
userId: req.user._id,
|
||||
existingRecord: recentRecord._id
|
||||
});
|
||||
return res.status(409).json({
|
||||
error: '检测到重复提交',
|
||||
code: 'DUPLICATE_SUBMISSION',
|
||||
existingRecordId: recentRecord._id
|
||||
});
|
||||
}
|
||||
// 验证统计数据
|
||||
if (
|
||||
typeof stats.totalWords !== 'number' ||
|
||||
typeof stats.correctWords !== 'number' ||
|
||||
typeof stats.accuracy !== 'number' ||
|
||||
typeof stats.wordsPerMinute !== 'number' ||
|
||||
!stats.startTime ||
|
||||
!stats.endTime ||
|
||||
typeof stats.duration !== 'number'
|
||||
) {
|
||||
console.error('Invalid stats data:', stats);
|
||||
return res.status(400).json({
|
||||
error: '练习统计数据无效',
|
||||
code: 'INVALID_STATS'
|
||||
});
|
||||
}
|
||||
|
||||
// 创建记录
|
||||
const record = new PracticeRecord({
|
||||
userId: new Types.ObjectId(req.user._id),
|
||||
username: req.user.username,
|
||||
fullname: req.user.fullname,
|
||||
type,
|
||||
stats: {
|
||||
...stats,
|
||||
startTime: new Date(stats.startTime),
|
||||
endTime: new Date(stats.endTime),
|
||||
},
|
||||
inputEvents: req.body.inputEvents
|
||||
});
|
||||
|
||||
// 保存练习记录 (移除 session)
|
||||
await record.save();
|
||||
|
||||
// 查找用户并更新统计信息
|
||||
const user = await User.findById(req.user._id);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// 更新用户统计信息
|
||||
if (!user.stats) {
|
||||
user.stats = {
|
||||
totalPracticeCount: 0,
|
||||
totalWords: 0,
|
||||
totalAccuracy: 0,
|
||||
totalSpeed: 0,
|
||||
accuracyHistory: [],
|
||||
todayPracticeTime: 0,
|
||||
lastPracticeDate: new Date()
|
||||
};
|
||||
}
|
||||
await user.updatePracticeStats({
|
||||
words: stats.totalWords,
|
||||
accuracy: stats.accuracy,
|
||||
duration: stats.duration,
|
||||
speed: stats.wordsPerMinute
|
||||
});
|
||||
|
||||
console.log('Practice record and user stats updated successfully');
|
||||
res.status(201).json(record);
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('保存练习记录失败:', {
|
||||
error,
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
|
||||
if (error instanceof MongooseError.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: '数据验证失败',
|
||||
details: error.message,
|
||||
code: 'VALIDATION_ERROR'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: '保存练习记录失败',
|
||||
code: 'SAVE_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取用户统计信息
|
||||
router.get('/statistics', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({
|
||||
error: '用户信息无效',
|
||||
code: 'INVALID_USER'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user._id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: '用户不存在',
|
||||
code: 'USER_NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
practiceCount: user.stats.totalPracticeCount || 0,
|
||||
totalWords: user.stats.totalWords || 0,
|
||||
avgAccuracy: user.stats.totalAccuracy || 0, // 直接使用存储的平均准确率
|
||||
avgSpeed: user.stats.totalSpeed || 0,
|
||||
todayPracticeTime: user.stats.todayPracticeTime || 0,
|
||||
accuracyTrend: user.stats.accuracyHistory.slice(-10), // 取最近10次的准确率记录
|
||||
lastPracticeDate: user.stats.lastPracticeDate || null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取统计数据失败',
|
||||
code: 'FETCH_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
// 获取用户的练习记录
|
||||
router.get('/my-records', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({
|
||||
error: '用户信息无效',
|
||||
code: 'INVALID_USER'
|
||||
});
|
||||
}
|
||||
|
||||
const records = await PracticeRecord.find({
|
||||
userId: new Types.ObjectId(req.user._id)
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.select('-__v'); // 排除版本字段
|
||||
|
||||
console.log('Retrieved records count:', records.length);
|
||||
|
||||
res.json(records);
|
||||
} catch (error) {
|
||||
console.error('获取记录失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取记录失败',
|
||||
code: 'FETCH_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 管理员获取所有练习记录
|
||||
router.get('/all', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
if (!req.user?.isAdmin) {
|
||||
return res.status(403).json({
|
||||
error: '需要管理员权限',
|
||||
code: 'FORBIDDEN'
|
||||
});
|
||||
}
|
||||
|
||||
const { date, search } = req.query;
|
||||
const query: any = {};
|
||||
|
||||
// 添加日期筛选
|
||||
if (date) {
|
||||
const startDate = new Date(date as string);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
|
||||
query.createdAt = {
|
||||
$gte: startDate,
|
||||
$lt: endDate
|
||||
};
|
||||
}
|
||||
|
||||
// 添加搜索条件
|
||||
if (search) {
|
||||
const searchRegex = new RegExp(search as string, 'i');
|
||||
query['$or'] = [
|
||||
{ 'userInfo.fullname': searchRegex },
|
||||
{ 'userInfo.username': searchRegex }
|
||||
];
|
||||
}
|
||||
|
||||
const records = await PracticeRecord.aggregate([
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo'
|
||||
}
|
||||
},
|
||||
{ $unwind: '$userInfo' },
|
||||
{ $match: query }, // 移动 $match 到 $lookup 后面以支持用户信息搜索
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
type: 1,
|
||||
stats: 1,
|
||||
createdAt: 1,
|
||||
fullname: '$userInfo.fullname',
|
||||
username: '$userInfo.username'
|
||||
}
|
||||
},
|
||||
{ $sort: { fullname: 1, createdAt: -1 } }
|
||||
]);
|
||||
|
||||
res.json(records);
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取所有练习记录失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取记录失败',
|
||||
code: 'FETCH_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取特定记录的详情
|
||||
router.get('/:id', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!Types.ObjectId.isValid(req.params.id)) {
|
||||
return res.status(400).json({
|
||||
error: '无效的记录ID',
|
||||
code: 'INVALID_ID'
|
||||
});
|
||||
}
|
||||
|
||||
const record = await PracticeRecord.findById(req.params.id)
|
||||
.select('-__v');
|
||||
|
||||
if (!record) {
|
||||
return res.status(404).json({
|
||||
error: '记录不存在',
|
||||
code: 'NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证用户是否有权限访问该记录
|
||||
if (record.userId.toString() !== req.user?._id) {
|
||||
return res.status(403).json({
|
||||
error: '无权访问此记录',
|
||||
code: 'FORBIDDEN'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
console.error('获取记录失败:', error);
|
||||
res.status(500).json({
|
||||
error: '获取记录失败',
|
||||
code: 'FETCH_ERROR'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
server/routes/practiceTypes.ts
Normal file
15
server/routes/practiceTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from 'express';
|
||||
import { PracticeType } from '../models/PracticeType';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const practiceTypes = await PracticeType.find();
|
||||
res.json(practiceTypes);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching practice types' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
89
server/routes/sudoku.ts
Normal file
89
server/routes/sudoku.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { SudokuRecord, SudokuDifficulty } from '../models/SudokuRecord';
|
||||
import { auth } from '../middleware/auth';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 提交数独记录(需要登录)
|
||||
router.post('/record', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { difficulty, timeSeconds, won } = req.body;
|
||||
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard'];
|
||||
if (!validDifficulties.includes(difficulty)) {
|
||||
return res.status(400).json({ error: '无效的难度级别' });
|
||||
}
|
||||
|
||||
if (typeof timeSeconds !== 'number' || timeSeconds < 0) {
|
||||
return res.status(400).json({ error: '无效的时间' });
|
||||
}
|
||||
|
||||
if (typeof won !== 'boolean') {
|
||||
return res.status(400).json({ error: '无效的游戏结果' });
|
||||
}
|
||||
|
||||
const record = new SudokuRecord({
|
||||
userId: req.user._id,
|
||||
username: req.user.username,
|
||||
fullname: req.user.fullname || req.user.username,
|
||||
difficulty,
|
||||
timeSeconds,
|
||||
won
|
||||
});
|
||||
|
||||
await record.save();
|
||||
|
||||
res.status(201).json({
|
||||
message: '游戏记录保存成功',
|
||||
record
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存数独记录失败:', error);
|
||||
res.status(500).json({ error: '保存数独记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取数独排行榜
|
||||
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { difficulty } = req.params;
|
||||
const { page = '1', limit = '10' } = req.query;
|
||||
|
||||
const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard'];
|
||||
if (!validDifficulties.includes(difficulty as SudokuDifficulty)) {
|
||||
return res.status(400).json({ error: '无效的难度级别' });
|
||||
}
|
||||
|
||||
const pageNum = parseInt(page as string, 10);
|
||||
const pageSize = parseInt(limit as string, 10);
|
||||
const skipCount = (pageNum - 1) * pageSize;
|
||||
|
||||
const records = await SudokuRecord.getLeaderboard(
|
||||
difficulty as SudokuDifficulty,
|
||||
skipCount,
|
||||
pageSize
|
||||
);
|
||||
|
||||
const totalUsers = await SudokuRecord.distinct('userId', {
|
||||
difficulty,
|
||||
won: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: totalUsers.length,
|
||||
currentPage: pageNum,
|
||||
totalPages: Math.ceil(totalUsers.length / pageSize),
|
||||
difficulty
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取数独排行榜失败:', error);
|
||||
res.status(500).json({ error: '获取数独排行榜失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
11
server/routes/system.ts
Normal file
11
server/routes/system.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// server/routes/system.ts
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/server-time', (req, res) => {
|
||||
res.json({
|
||||
serverTime: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
227
server/routes/towerDefense.ts
Normal file
227
server/routes/towerDefense.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
// server/routes/towerDefense.ts
|
||||
import express, { Request, Response } from 'express';
|
||||
import { TowerDefenseRecord } from '../models/TowerDefenseRecord';
|
||||
import { TowerDefenseSave } from '../models/TowerDefenseSave';
|
||||
import { auth } from '../middleware/auth';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 简单的内存去重/速率限制(仅用于示例,生产环境请使用 Redis 等持久/分布式存储)
|
||||
const lastSubmissionByUser = new Map<string, { ts: number; score: number; wave: number }>();
|
||||
const submissionWindow = new Map<string, { windowStart: number; count: number }>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const RATE_LIMIT_MAX = 20; // max submissions per user per window
|
||||
|
||||
// 提交塔防记录(需要登录)
|
||||
router.post('/record', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { wave, score, timeSeconds } = req.body;
|
||||
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const uid = String(req.user._id);
|
||||
|
||||
// rate limit
|
||||
try {
|
||||
const now = Date.now();
|
||||
const win = submissionWindow.get(uid) || { windowStart: now, count: 0 };
|
||||
if (now - win.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
win.windowStart = now;
|
||||
win.count = 0;
|
||||
}
|
||||
win.count++;
|
||||
submissionWindow.set(uid, win);
|
||||
if (win.count > RATE_LIMIT_MAX) {
|
||||
return res.status(429).json({ error: '提交过于频繁,请稍后再试' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('rate limit check error', e);
|
||||
}
|
||||
|
||||
if (typeof wave !== 'number' || wave < 0) {
|
||||
return res.status(400).json({ error: '无效的波次数据' });
|
||||
}
|
||||
|
||||
if (typeof score !== 'number' || score < 0) {
|
||||
return res.status(400).json({ error: '无效的分数数据' });
|
||||
}
|
||||
|
||||
if (typeof timeSeconds !== 'number' || timeSeconds < 0) {
|
||||
return res.status(400).json({ error: '无效的游戏时长' });
|
||||
}
|
||||
|
||||
// dedupe:防止重复上报(比如 iframe 连续发送多次)
|
||||
try {
|
||||
const last = lastSubmissionByUser.get(uid);
|
||||
const now = Date.now();
|
||||
if (last && last.score === score && last.wave === wave && (now - last.ts) < 5000) {
|
||||
// 视为重复提交
|
||||
return res.status(200).json({ message: '重复提交,已忽略' });
|
||||
}
|
||||
// 保存最近提交摘要
|
||||
lastSubmissionByUser.set(uid, { ts: now, score, wave });
|
||||
} catch (e) {
|
||||
console.warn('dedupe check error', e);
|
||||
}
|
||||
|
||||
const record = new TowerDefenseRecord({
|
||||
userId: req.user._id,
|
||||
username: req.user.username,
|
||||
fullname: req.user.fullname || req.user.username,
|
||||
wave,
|
||||
score,
|
||||
timeSeconds
|
||||
});
|
||||
|
||||
await record.save();
|
||||
|
||||
// 存储成功后可以清理或记录更多指标
|
||||
// (保留 lastSubmission 已记录)
|
||||
|
||||
res.status(201).json({
|
||||
message: '记录保存成功',
|
||||
record
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存塔防记录失败:', error);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取排行榜
|
||||
router.get('/leaderboard', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { page = '1', limit = '10' } = req.query;
|
||||
const pageNum = parseInt(page as string);
|
||||
const pageSize = parseInt(limit as string);
|
||||
const skipCount = (pageNum - 1) * pageSize;
|
||||
|
||||
const records = await TowerDefenseRecord.getLeaderboard(skipCount, pageSize);
|
||||
const totalUsers = await TowerDefenseRecord.distinct('userId');
|
||||
|
||||
res.json({
|
||||
records,
|
||||
total: totalUsers.length,
|
||||
currentPage: pageNum,
|
||||
totalPages: Math.ceil(totalUsers.length / pageSize)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({ error: '获取排行榜失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取个人最佳
|
||||
router.get('/personal-best', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const bestRecord = await TowerDefenseRecord.findOne({
|
||||
userId: req.user._id
|
||||
}).sort({ score: -1 });
|
||||
|
||||
if (!bestRecord) {
|
||||
return res.json({ hasBest: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
hasBest: true,
|
||||
bestScore: bestRecord.score,
|
||||
bestWave: bestRecord.wave,
|
||||
createdAt: bestRecord.createdAt
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取个人最佳失败:', error);
|
||||
res.status(500).json({ error: '获取个人最佳失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 保存当前游戏进度(需要登录)
|
||||
router.post('/save', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const { state } = req.body;
|
||||
if (!state) {
|
||||
return res.status(400).json({ error: '缺少保存的游戏状态' });
|
||||
}
|
||||
|
||||
const name = String(Date.now());
|
||||
|
||||
const saveDoc = new TowerDefenseSave({
|
||||
userId: req.user._id,
|
||||
name,
|
||||
state
|
||||
});
|
||||
|
||||
await saveDoc.save();
|
||||
|
||||
res.status(201).json({ message: '已保存进度', save: saveDoc });
|
||||
} catch (error) {
|
||||
console.error('保存游戏进度失败:', error);
|
||||
res.status(500).json({ error: '保存游戏进度失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 列出当前用户的保存记录
|
||||
router.get('/saves', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const saves = await TowerDefenseSave.find({ userId: req.user._id }).sort({ createdAt: -1 }).limit(50);
|
||||
res.json({ saves });
|
||||
} catch (error) {
|
||||
console.error('获取保存列表失败:', error);
|
||||
res.status(500).json({ error: '获取保存列表失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取指定的保存项
|
||||
router.get('/save/:id', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const save = await TowerDefenseSave.findById(id);
|
||||
if (!save) return res.status(404).json({ error: '未找到保存项' });
|
||||
if (String(save.userId) !== String(req.user._id)) return res.status(403).json({ error: '无权访问该保存项' });
|
||||
|
||||
res.json({ save });
|
||||
} catch (error) {
|
||||
console.error('获取保存项失败:', error);
|
||||
res.status(500).json({ error: '获取保存项失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除指定保存项
|
||||
router.delete('/save/:id', auth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user?._id) {
|
||||
return res.status(401).json({ error: '未登录' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const save = await TowerDefenseSave.findById(id);
|
||||
if (!save) return res.status(404).json({ error: '未找到保存项' });
|
||||
if (String(save.userId) !== String(req.user._id)) return res.status(403).json({ error: '无权删除该保存项' });
|
||||
|
||||
await TowerDefenseSave.deleteOne({ _id: id });
|
||||
res.json({ message: '已删除' });
|
||||
} catch (error) {
|
||||
console.error('删除保存项失败:', error);
|
||||
res.status(500).json({ error: '删除保存项失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
56
server/routes/userWordPass.ts
Normal file
56
server/routes/userWordPass.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import express from 'express';
|
||||
import { User } from '../models/User';
|
||||
import { WordRecord } from '../models/Vocabulary';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 查询指定用户名前缀的用户及其通过单词数
|
||||
* GET /api/user-word-pass?prefix=2023101
|
||||
*/
|
||||
router.get('/user-word-pass', async (req, res) => {
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix || typeof prefix !== 'string') {
|
||||
return res.status(400).json({ message: '缺少前缀参数' });
|
||||
}
|
||||
|
||||
// 1. 查找所有以 prefix 开头的用户
|
||||
const users = await User.find({ username: { $regex: `^${prefix}` } })
|
||||
.select('_id username fullname')
|
||||
.lean();
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// 2. 查找这些用户的通过单词数
|
||||
const userIds = users.map(u => u._id);
|
||||
|
||||
// 聚合统计每个用户 isFullyMastered 为 true 的数量
|
||||
const wordPassStats = await WordRecord.aggregate([
|
||||
{ $match: { user: { $in: userIds }, isFullyMastered: true } },
|
||||
{ $group: { _id: '$user', passCount: { $sum: 1 } } }
|
||||
]);
|
||||
|
||||
// 组装统计结果
|
||||
const passMap = new Map<string, number>();
|
||||
wordPassStats.forEach(item => {
|
||||
passMap.set(item._id.toString(), item.passCount);
|
||||
});
|
||||
|
||||
// 3. 合并并排序
|
||||
const result = users.map(u => ({
|
||||
username: u.username,
|
||||
fullname: u.fullname,
|
||||
passCount: passMap.get(u._id.toString()) || 0
|
||||
})).sort((a, b) => b.passCount - a.passCount);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('查询用户通过单词数失败:', error);
|
||||
res.status(500).json({ message: '服务器错误' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
24
server/routes/visitor.ts
Normal file
24
server/routes/visitor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取访问者IP地址
|
||||
router.get('/ip', (req, res) => {
|
||||
try {
|
||||
// 获取X-Forwarded-For头信息(如果通过代理)
|
||||
const forwardedIp = req.headers['x-forwarded-for'];
|
||||
|
||||
// 如果存在X-Forwarded-For,则使用第一个IP(最接近用户的代理)
|
||||
// 否则使用直接连接的IP
|
||||
const ip = forwardedIp
|
||||
? (typeof forwardedIp === 'string' ? forwardedIp.split(',')[0].trim() : forwardedIp[0])
|
||||
: req.ip || req.connection.remoteAddress;
|
||||
|
||||
res.json({ ip });
|
||||
} catch (error) {
|
||||
console.error('获取IP地址错误:', error);
|
||||
res.status(500).json({ message: '获取IP地址失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
699
server/routes/vocabulary.ts
Normal file
699
server/routes/vocabulary.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
// server/routes/vocabulary.ts
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { auth as authMiddleware } from '../middleware/auth';
|
||||
import { Word, WordSet, WordRecord, VocabularyTestRecord } from '../models/Vocabulary';
|
||||
import mongoose from 'mongoose';
|
||||
import csv from 'csv-parser';
|
||||
import { User } from '../models/User';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 配置 multer 用于文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
// 确保上传目录存在
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `${Date.now()}-${file.originalname}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 限制5MB
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
// 只允许上传CSV文件
|
||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只支持CSV文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有单词集
|
||||
router.get('/word-sets', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const wordSets = await WordSet.find()
|
||||
.select('name description totalWords createdAt')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
res.json(wordSets);
|
||||
} catch (error) {
|
||||
console.error('获取单词集失败:', error);
|
||||
res.status(500).json({ message: '获取单词集失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建新的单词集
|
||||
router.post('/word-sets', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
// 检查是否已存在同名单词集
|
||||
const existingSet = await WordSet.findOne({
|
||||
name: name
|
||||
});
|
||||
|
||||
if (existingSet) {
|
||||
return res.status(400).json({ message: '已存在同名单词集' });
|
||||
}
|
||||
|
||||
const newWordSet = await WordSet.create({
|
||||
name,
|
||||
description,
|
||||
totalWords: 0
|
||||
});
|
||||
|
||||
res.status(201).json(newWordSet);
|
||||
} catch (error) {
|
||||
console.error('创建单词集失败:', error);
|
||||
res.status(500).json({ message: '创建单词集失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传单词文件并创建单词集
|
||||
router.post('/upload', authMiddleware, upload.single('file'), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: '请上传文件' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { name } = req.body;
|
||||
const fileName = req.file.filename;
|
||||
const filePath = req.file.path;
|
||||
|
||||
// 检查是否已存在同名单词集
|
||||
const existingSet = await WordSet.findOne({
|
||||
name: name || path.basename(fileName, '.csv')
|
||||
});
|
||||
|
||||
if (existingSet) {
|
||||
// 删除上传的文件
|
||||
fs.unlinkSync(filePath);
|
||||
return res.status(400).json({ message: '已存在同名单词集' });
|
||||
}
|
||||
|
||||
// 创建单词集
|
||||
const wordSet = await WordSet.create({
|
||||
name: name || path.basename(fileName, '.csv'),
|
||||
totalWords: 0
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
let wordCount = 0;
|
||||
|
||||
// 处理CSV文件
|
||||
fs.createReadStream(filePath)
|
||||
.pipe(csv())
|
||||
.on('data', (data) => {
|
||||
// 检查必要的字段是否存在
|
||||
if (data.word && data.translation) {
|
||||
results.push({
|
||||
word: data.word.trim(),
|
||||
translation: data.translation.trim(),
|
||||
pronunciation: data.pronunciation?.trim(),
|
||||
example: data.example?.trim(),
|
||||
wordSet: wordSet._id
|
||||
});
|
||||
wordCount++;
|
||||
}
|
||||
})
|
||||
.on('end', async () => {
|
||||
try {
|
||||
// 批量插入单词
|
||||
if (results.length > 0) {
|
||||
await Word.insertMany(results);
|
||||
|
||||
// 更新单词集的单词数量
|
||||
await WordSet.findByIdAndUpdate(wordSet._id, { totalWords: wordCount });
|
||||
}
|
||||
|
||||
// 删除上传的文件
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.status(201).json({
|
||||
message: `成功创建单词集并导入${wordCount}个单词`,
|
||||
wordSet
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果插入单词失败,删除创建的单词集
|
||||
await WordSet.findByIdAndDelete(wordSet._id);
|
||||
|
||||
console.error('导入单词失败:', error);
|
||||
res.status(500).json({ message: '导入单词失败' });
|
||||
}
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.error('处理CSV文件失败:', error);
|
||||
res.status(500).json({ message: '处理CSV文件失败' });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传文件失败:', error);
|
||||
res.status(500).json({ message: '上传文件失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取学习单词(智能抽取算法,嵌套结构版)
|
||||
router.get('/study-words/:wordSetId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordSetId } = req.params;
|
||||
let targetCount = 100;
|
||||
if (req.query.count) {
|
||||
const parsed = parseInt(req.query.count as string, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
targetCount = Math.min(parsed, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查单词集是否存在
|
||||
const wordSet = await WordSet.findOne({ _id: wordSetId });
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
// 1. 获取该单词集下所有单词
|
||||
const allWords = await Word.find({ wordSet: wordSetId });
|
||||
const allWordIds = allWords.map(w => w._id.toString());
|
||||
|
||||
// 2. 获取该用户所有WordRecord(嵌套结构)
|
||||
const allRecords = await WordRecord.find({ user: req.user._id, word: { $in: allWordIds } });
|
||||
// 用 wordId 做 key
|
||||
const recordMap = new Map();
|
||||
allRecords.forEach(r => {
|
||||
recordMap.set(r.word.toString(), r);
|
||||
});
|
||||
|
||||
// 3. 分类
|
||||
const wrongBookWords: any[] = [];
|
||||
const notMasteredWords: any[] = [];
|
||||
const masteredWords: { word: any, lastMasteredAt: Date }[] = [];
|
||||
const neverLearnedWords: any[] = [];
|
||||
|
||||
for (const word of allWords) {
|
||||
const wordId = word._id.toString();
|
||||
const record = recordMap.get(wordId);
|
||||
|
||||
// 没有任何记录
|
||||
if (!record) {
|
||||
neverLearnedWords.push(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 判断是否在错词本
|
||||
const modes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'];
|
||||
const anyInWrongBook = modes.some(m => record[m]?.inWrongBook);
|
||||
if (anyInWrongBook) {
|
||||
wrongBookWords.push(word);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 判断是否全部掌握
|
||||
if (record.isFullyMastered) {
|
||||
masteredWords.push({ word, lastMasteredAt: record.lastFullyMasteredAt });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 未完全掌握但有学习记录
|
||||
if (modes.some(m => record[m] && Object.keys(record[m]).length > 0)) {
|
||||
notMasteredWords.push(word);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤一周内掌握的单词
|
||||
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
let eligibleMasteredWords = masteredWords.filter(item =>
|
||||
item.lastMasteredAt && item.lastMasteredAt < oneWeekAgo
|
||||
);
|
||||
|
||||
// 4. 按比例抽取
|
||||
let wrongBookQuota = Math.round(targetCount * 0.4);
|
||||
let masteredQuota = Math.round(targetCount * 0.1);
|
||||
let neverLearnedQuota = targetCount - wrongBookQuota - masteredQuota; // 剩下的给未学和新词
|
||||
|
||||
// 错词本优先
|
||||
let selectedWrongBook = wrongBookWords.slice(0, wrongBookQuota);
|
||||
let selectedMastered = eligibleMasteredWords
|
||||
.sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0))
|
||||
.slice(0, masteredQuota)
|
||||
.map(item => item.word);
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
let selectedNeverLearned = shuffle([...neverLearnedWords]).slice(0, neverLearnedQuota);
|
||||
|
||||
// 补足不足部分
|
||||
let remain = targetCount - (selectedWrongBook.length + selectedMastered.length + selectedNeverLearned.length);
|
||||
// 先补未掌握(不在错词本的)
|
||||
let notMasteredPool = notMasteredWords.filter(w => !selectedWrongBook.includes(w));
|
||||
let selectedNotMastered: any[] = [];
|
||||
if (remain > 0 && notMasteredPool.length > 0) {
|
||||
selectedNotMastered = notMasteredPool.slice(0, remain);
|
||||
remain -= selectedNotMastered.length;
|
||||
}
|
||||
// 再补新词
|
||||
if (remain > 0) {
|
||||
const moreNew = neverLearnedWords.slice(neverLearnedQuota, neverLearnedQuota + remain);
|
||||
selectedNeverLearned = selectedNeverLearned.concat(moreNew);
|
||||
remain -= moreNew.length;
|
||||
}
|
||||
// 再补已掌握
|
||||
if (remain > 0) {
|
||||
const moreMastered = eligibleMasteredWords
|
||||
.sort((a, b) => (a.lastMasteredAt?.getTime() || 0) - (b.lastMasteredAt?.getTime() || 0))
|
||||
.slice(masteredQuota, masteredQuota + remain)
|
||||
.map(item => item.word);
|
||||
selectedMastered = selectedMastered.concat(moreMastered);
|
||||
remain -= moreMastered.length;
|
||||
}
|
||||
// 再补错词本
|
||||
if (remain > 0) {
|
||||
const moreWrong = wrongBookWords.slice(wrongBookQuota, wrongBookQuota + remain);
|
||||
selectedWrongBook = selectedWrongBook.concat(moreWrong);
|
||||
remain -= moreWrong.length;
|
||||
}
|
||||
|
||||
// 合并所有选中的单词
|
||||
let finalWords = [
|
||||
...selectedWrongBook,
|
||||
...selectedNotMastered,
|
||||
...selectedMastered,
|
||||
...selectedNeverLearned
|
||||
];
|
||||
// 去重
|
||||
const seen = new Set();
|
||||
finalWords = finalWords.filter(w => {
|
||||
const id = w._id.toString();
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
// 最终数量限制
|
||||
finalWords = finalWords.slice(0, targetCount);
|
||||
|
||||
res.json(finalWords);
|
||||
} catch (error) {
|
||||
console.error('获取学习单词失败:', error);
|
||||
res.status(500).json({ message: '获取学习单词失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 记录单词学习结果(嵌套结构版,upsert防止重复)
|
||||
router.post('/word-record', (req, res, next) => {
|
||||
console.log('收到 /word-record 请求', req.method, req.body);
|
||||
next();
|
||||
}, authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordId, isCorrect, testType } = req.body;
|
||||
const userId = req.user._id;
|
||||
|
||||
// 检查单词是否存在
|
||||
const word = await Word.findById(wordId);
|
||||
if (!word) {
|
||||
return res.status(404).json({ message: '未找到单词' });
|
||||
}
|
||||
|
||||
// 嵌套字段名映射
|
||||
const modeMap = {
|
||||
'chinese-to-english': 'chineseToEnglish',
|
||||
'audio-to-english': 'audioToEnglish',
|
||||
'multiple-choice': 'multipleChoice'
|
||||
};
|
||||
const modeKey = modeMap[testType];
|
||||
if (!modeKey) {
|
||||
return res.status(400).json({ message: '未知的测试类型' });
|
||||
}
|
||||
|
||||
// 先查当前记录
|
||||
let record = await WordRecord.findOne({ user: userId, word: wordId });
|
||||
let modeObj = record ? (record[modeKey] || {}) : {};
|
||||
|
||||
// 更新 streak、totalCorrect、totalWrong
|
||||
if (isCorrect) {
|
||||
modeObj.streak = (modeObj.streak || 0) + 1;
|
||||
modeObj.totalCorrect = (modeObj.totalCorrect || 0) + 1;
|
||||
} else {
|
||||
modeObj.streak = 0;
|
||||
modeObj.totalWrong = (modeObj.totalWrong || 0) + 1;
|
||||
}
|
||||
modeObj.lastTestedAt = new Date();
|
||||
|
||||
// 判定是否掌握
|
||||
if (modeObj.streak >= 1) {
|
||||
modeObj.mastered = true;
|
||||
modeObj.lastMasteredAt = new Date();
|
||||
modeObj.inWrongBook = false; // 掌握后自动移出错词本
|
||||
}
|
||||
|
||||
// 判定是否进入错词本
|
||||
if (!modeObj.mastered && modeObj.totalWrong >= 5) {
|
||||
modeObj.inWrongBook = true;
|
||||
}
|
||||
|
||||
// 构造更新对象
|
||||
const updateObj = {};
|
||||
updateObj[modeKey] = modeObj;
|
||||
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ user: userId, word: wordId },
|
||||
{ $set: updateObj, $setOnInsert: { user: userId, word: wordId, createdAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
// 检查三种模式是否都已掌握
|
||||
const allModes = ['chineseToEnglish', 'audioToEnglish', 'multipleChoice'];
|
||||
const isFullyMastered = allModes.every(m => record[m]?.mastered);
|
||||
let lastMasteredAt: Date | null = null;
|
||||
if (isFullyMastered) {
|
||||
// 取三种模式中最近一次掌握的时间
|
||||
lastMasteredAt = allModes.reduce((latest: Date | null, m) => {
|
||||
const t = record[m]?.lastMasteredAt;
|
||||
if (!latest && t) return t;
|
||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
||||
return t;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
// 取三种模式中最近一次测试的时间,如果比掌握时间更新则使用测试时间
|
||||
const lastTestedAt = allModes.reduce((latest: Date | null, m) => {
|
||||
const t = record[m]?.lastTestedAt;
|
||||
if (!latest && t) return t;
|
||||
if (t instanceof Date && latest instanceof Date && t > latest) {
|
||||
return t;
|
||||
}
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
// 使用最新的日期(掌握时间或测试时间)
|
||||
if (lastTestedAt && (!lastMasteredAt || lastTestedAt > lastMasteredAt)) {
|
||||
lastMasteredAt = lastTestedAt;
|
||||
}
|
||||
|
||||
// 更新isFullyMastered和lastFullyMasteredAt字段
|
||||
if (!record.isFullyMastered || !record.lastFullyMasteredAt ||
|
||||
(lastMasteredAt && record.lastFullyMasteredAt < lastMasteredAt)) {
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: record._id },
|
||||
{
|
||||
$set: {
|
||||
isFullyMastered: true,
|
||||
lastFullyMasteredAt: lastMasteredAt
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
} else if (record.isFullyMastered) {
|
||||
// 如果之前标记为完全掌握,但现在不是,更新状态
|
||||
record = await WordRecord.findOneAndUpdate(
|
||||
{ _id: record._id },
|
||||
{ $set: { isFullyMastered: false } },
|
||||
{ new: true }
|
||||
);
|
||||
}
|
||||
|
||||
// 返回当前单词的所有模式掌握状态和是否在错词本
|
||||
const masteryStatus = {};
|
||||
allModes.forEach(m => {
|
||||
masteryStatus[m] = {
|
||||
mastered: record[m]?.mastered || false,
|
||||
streak: record[m]?.streak || 0,
|
||||
totalCorrect: record[m]?.totalCorrect || 0,
|
||||
totalWrong: record[m]?.totalWrong || 0,
|
||||
inWrongBook: record[m]?.inWrongBook || false,
|
||||
lastMasteredAt: record[m]?.lastMasteredAt || null
|
||||
};
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: '记录已保存',
|
||||
masteryStatus,
|
||||
isFullyMastered: record.isFullyMastered,
|
||||
lastMasteredAt: record.lastFullyMasteredAt,
|
||||
inWrongBook: allModes.some(m => record[m]?.inWrongBook)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存单词学习记录失败:', error);
|
||||
res.status(500).json({ message: '保存单词学习记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 保存测试记录
|
||||
router.post('/test-record', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordSetId, testType, stats } = req.body;
|
||||
|
||||
// 检查单词集是否存在并属于当前用户
|
||||
const wordSet = await WordSet.findOne({
|
||||
_id: wordSetId
|
||||
});
|
||||
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
|
||||
// 创建测试记录
|
||||
await VocabularyTestRecord.create({
|
||||
user: req.user._id,
|
||||
wordSet: wordSetId,
|
||||
testType,
|
||||
stats: {
|
||||
totalWords: stats.totalWords,
|
||||
correctWords: stats.correctWords,
|
||||
accuracy: stats.accuracy,
|
||||
startTime: new Date(stats.startTime),
|
||||
endTime: new Date(stats.endTime),
|
||||
duration: stats.duration
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ message: '测试记录已保存' });
|
||||
} catch (error) {
|
||||
console.error('保存测试记录失败:', error);
|
||||
res.status(500).json({ message: '保存测试记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单词详情
|
||||
router.get('/word/:wordId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { wordId } = req.params;
|
||||
|
||||
const word = await Word.findById(wordId);
|
||||
if (!word) {
|
||||
return res.status(404).json({ message: '未找到单词' });
|
||||
}
|
||||
|
||||
// 检查用户是否有权限访问这个单词
|
||||
const wordSet = await WordSet.findOne({
|
||||
_id: word.wordSet
|
||||
});
|
||||
|
||||
if (!wordSet) {
|
||||
return res.status(403).json({ message: '没有权限访问该单词' });
|
||||
}
|
||||
|
||||
res.json(word);
|
||||
} catch (error) {
|
||||
console.error('获取单词详情失败:', error);
|
||||
res.status(500).json({ message: '获取单词详情失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单词测试记录
|
||||
router.get('/test-records', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const testRecords = await VocabularyTestRecord.find({
|
||||
user: req.user._id
|
||||
}).populate('wordSet', 'name').sort({ createdAt: -1 });
|
||||
|
||||
res.json(testRecords);
|
||||
} catch (error) {
|
||||
console.error('获取测试记录失败:', error);
|
||||
res.status(500).json({ message: '获取测试记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 排行榜接口:按掌握单词数和正确率排序
|
||||
router.get('/leaderboard', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
// 1. 查出所有 WordRecord
|
||||
const allRecords = await WordRecord.find({});
|
||||
|
||||
// 2. 直接使用isFullyMastered统计每个用户掌握的单词数
|
||||
const userMasteredCount = {};
|
||||
const userStats = {};
|
||||
|
||||
for (const rec of allRecords) {
|
||||
const userId = rec.user.toString();
|
||||
|
||||
// 初始化用户统计数据
|
||||
if (!userStats[userId]) {
|
||||
userStats[userId] = { correct: 0, total: 0 };
|
||||
}
|
||||
|
||||
// 使用isFullyMastered直接统计掌握单词数
|
||||
if (rec.isFullyMastered) {
|
||||
userMasteredCount[userId] = (userMasteredCount[userId] || 0) + 1;
|
||||
}
|
||||
|
||||
// 统计正确率
|
||||
['chineseToEnglish', 'audioToEnglish', 'multipleChoice'].forEach(mode => {
|
||||
const m = rec[mode];
|
||||
if (m) {
|
||||
userStats[userId].correct += m.totalCorrect || 0;
|
||||
userStats[userId].total += (m.totalCorrect || 0) + (m.totalWrong || 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 查询用户名
|
||||
const userIds = Object.keys(userMasteredCount);
|
||||
const users = await mongoose.model('User').find({ _id: { $in: userIds } }, { fullname: 1 });
|
||||
const userMap = {};
|
||||
users.forEach(u => { userMap[u._id.toString()] = u.fullname; });
|
||||
|
||||
// 4. 组装排行榜数组
|
||||
const leaderboard = userIds.map(userId => ({
|
||||
userId,
|
||||
fullname: userMap[userId] || '未知用户',
|
||||
totalWordsLearned: userMasteredCount[userId],
|
||||
accuracy: userStats[userId] && userStats[userId].total > 0
|
||||
? Math.round((userStats[userId].correct / userStats[userId].total) * 100)
|
||||
: 0,
|
||||
rank: 0 // 添加rank属性,初始值为0
|
||||
}));
|
||||
|
||||
// 5. 排序
|
||||
leaderboard.sort((a, b) => {
|
||||
if (b.totalWordsLearned !== a.totalWordsLearned) {
|
||||
return b.totalWordsLearned - a.totalWordsLearned;
|
||||
}
|
||||
return b.accuracy - a.accuracy;
|
||||
});
|
||||
|
||||
// 6. 添加排名
|
||||
leaderboard.forEach((item, idx) => {
|
||||
item.rank = idx + 1;
|
||||
});
|
||||
|
||||
res.json(leaderboard);
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({ message: '获取排行榜失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单词集详细信息
|
||||
router.get('/word-set/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const wordSet = await WordSet.findById(req.params.id);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
res.json(wordSet);
|
||||
} catch (error) {
|
||||
console.error('获取单词集详细信息失败:', error);
|
||||
res.status(500).json({ message: '获取单词集详细信息失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新单词集
|
||||
router.put('/word-set/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
const wordSet = await WordSet.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ name, description },
|
||||
{ new: true }
|
||||
);
|
||||
if (!wordSet) {
|
||||
return res.status(404).json({ message: '未找到单词集' });
|
||||
}
|
||||
res.json(wordSet);
|
||||
} catch (error) {
|
||||
console.error('更新单词集失败:', error);
|
||||
res.status(500).json({ message: '更新单词集失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单词集中的单词
|
||||
router.get('/word-set/:id/words', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const wordSetId = req.params.id;
|
||||
console.log('Received request for wordSetId:', wordSetId); // 打印请求的 wordSetId
|
||||
|
||||
// 检查数据库中是否存在该单词集
|
||||
const wordSetExists = await WordSet.exists({ _id: wordSetId });
|
||||
console.log('WordSet exists:', wordSetExists); // 打印单词集是否存在
|
||||
|
||||
if (!wordSetExists) {
|
||||
console.log('WordSet not found for wordSetId:', wordSetId);
|
||||
return res.status(404).json({ message: '请求的资源不存在' });
|
||||
}
|
||||
|
||||
// 查询单词集中的单词
|
||||
const words = await Word.find({ wordSet: wordSetId });
|
||||
console.log('Words found:', words.length); // 打印找到的单词数量
|
||||
|
||||
if (!words || words.length === 0) {
|
||||
console.log('No words found for wordSetId:', wordSetId); // 打印未找到的情况
|
||||
return res.status(404).json({ message: '请求的资源不存在' });
|
||||
}
|
||||
|
||||
res.json(words);
|
||||
} catch (error) {
|
||||
console.error('获取单词失败:', error);
|
||||
res.status(500).json({ message: '获取单词失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新单词
|
||||
router.put('/words', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { words } = req.body;
|
||||
console.log('Received words to update:', JSON.stringify(words, null, 2)); // 更详细的日志
|
||||
|
||||
for (const word of words) {
|
||||
const { _id, word: wordText, translation, pronunciation, example } = word;
|
||||
console.log(`Updating word ${_id}:`, { wordText, translation, pronunciation, example }); // 每个单词的更新日志
|
||||
|
||||
const updatedWord = await Word.findByIdAndUpdate(
|
||||
_id,
|
||||
{
|
||||
word: wordText,
|
||||
translation,
|
||||
pronunciation,
|
||||
example
|
||||
},
|
||||
{ new: true } // 返回更新后的文档
|
||||
);
|
||||
|
||||
console.log('Updated word result:', updatedWord); // 更新结果日志
|
||||
}
|
||||
res.json({ message: '单词更新成功' });
|
||||
} catch (error) {
|
||||
console.error('更新单词失败:', error);
|
||||
res.status(500).json({ message: '更新单词失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user