Migrate project from typing_practiceweb
This commit is contained in:
12
server/.env.example
Normal file
12
server/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# 服务器配置
|
||||
PORT=5001
|
||||
NODE_ENV=development
|
||||
|
||||
# 数据库配置
|
||||
MONGODB_URI=mongodb://localhost:27017/typeskill
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
79
server/config.ts
Normal file
79
server/config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// server/config.ts
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
// 环境变量类型定义
|
||||
interface Config {
|
||||
PORT: number;
|
||||
MONGODB_URI: string;
|
||||
JWT_SECRET: string;
|
||||
NODE_ENV: 'development' | 'production' | 'test';
|
||||
CORS_ORIGIN: string;
|
||||
}
|
||||
|
||||
// 环境变量验证
|
||||
const requiredEnvVars = ['MONGODB_URI', 'JWT_SECRET'] as const;
|
||||
for (const envVar of requiredEnvVars) {
|
||||
if (!process.env[envVar]) {
|
||||
throw new Error(`Missing required environment variable: ${envVar}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 配置对象
|
||||
export const config: Config = {
|
||||
PORT: parseInt(process.env.PORT || '5001', 10),
|
||||
MONGODB_URI: process.env.MONGODB_URI!,
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'your_jwt_secret_key_here',
|
||||
NODE_ENV: (process.env.NODE_ENV as Config['NODE_ENV']) || 'development',
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
};
|
||||
|
||||
// JWT 配置
|
||||
export const JWT_CONFIG = {
|
||||
expiresIn: '7d', // token 有效期
|
||||
issuer: 'typeskill', // 签发者
|
||||
};
|
||||
|
||||
// 数据库配置
|
||||
export const DB_CONFIG = {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
// 如果需要其他 MongoDB 配置选项,可以在这里添加
|
||||
};
|
||||
|
||||
// API 路径配置
|
||||
export const API_PATHS = {
|
||||
AUTH: '/api/auth',
|
||||
KEYWORDS: '/api/keywords',
|
||||
CODE_EXAMPLES: '/api/code-examples',
|
||||
PRACTICE_TYPES: '/api/practice-types',
|
||||
PRACTICE_RECORDS: '/api/practice-records',
|
||||
LEADERBOARD: '/api/leaderboard',
|
||||
ADMIN: {
|
||||
USERS: '/api/users',
|
||||
PRACTICE_RECORDS: '/api/practice-records/all'
|
||||
}
|
||||
} as const;
|
||||
|
||||
// 分页配置
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE_SIZE: 10,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
} as const;
|
||||
|
||||
// 安全配置
|
||||
export const SECURITY = {
|
||||
SALT_ROUNDS: 10, // 密码加密轮数
|
||||
PASSWORD_MIN_LENGTH: 6,
|
||||
PASSWORD_MAX_LENGTH: 50,
|
||||
} as const;
|
||||
|
||||
// 缓存配置
|
||||
export const CACHE = {
|
||||
TTL: 60 * 60, // 1小时(秒)
|
||||
} as const;
|
||||
|
||||
// 导出默认配置
|
||||
export default config;
|
||||
20
server/ecosystem.config.js
Normal file
20
server/ecosystem.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'typing-backend',
|
||||
script: 'server.ts',
|
||||
interpreter: 'ts-node',
|
||||
interpreter_args: '--transpile-only',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false, // 生产环境不要开启 watch
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 5001
|
||||
},
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true
|
||||
}]
|
||||
};
|
||||
26
server/global.d.ts
vendored
Normal file
26
server/global.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user: {
|
||||
_id: string;
|
||||
username: string;
|
||||
fullname?: string;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解决TypeScript类型错误
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Router {
|
||||
get: any;
|
||||
post: any;
|
||||
put: any;
|
||||
delete: any;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
205
server/middleware/auth.ts
Normal file
205
server/middleware/auth.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// server/middleware/auth.ts
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { config } from '../config';
|
||||
|
||||
// 用户接口
|
||||
interface UserPayload {
|
||||
_id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
// 扩展 Request 类型
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserPayload;
|
||||
token?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 认证错误类
|
||||
class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number = 401,
|
||||
public code: string = 'AUTH_ERROR'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并解析 JWT token
|
||||
* @param token JWT token
|
||||
* @returns 解析后的用户信息
|
||||
*/
|
||||
const verifyToken = (token: string): UserPayload => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET) as UserPayload;
|
||||
|
||||
console.debug('Token verification:', {
|
||||
userId: decoded._id,
|
||||
username: decoded.username,
|
||||
expiresIn: decoded.exp ? new Date(decoded.exp * 1000).toISOString() : 'unknown'
|
||||
});
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
console.debug('Token expired:', { error: error.message, expiredAt: error.expiredAt });
|
||||
throw new AuthError('认证令牌已过期', 401, 'TOKEN_EXPIRED');
|
||||
}
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
console.debug('Invalid token:', { error: error.message });
|
||||
throw new AuthError('无效的认证令牌', 401, 'INVALID_TOKEN');
|
||||
}
|
||||
console.error('Unexpected token verification error:', error);
|
||||
throw new AuthError('认证失败', 401, 'AUTH_FAILED');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证中间件
|
||||
*/
|
||||
export const auth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({
|
||||
error: '未提供认证令牌',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: '认证令牌格式无效',
|
||||
code: 'INVALID_TOKEN_FORMAT'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '').trim();
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: '认证令牌为空',
|
||||
code: 'EMPTY_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (!decoded._id || !decoded.username) {
|
||||
return res.status(401).json({
|
||||
error: '认证令牌信息不完整',
|
||||
code: 'INCOMPLETE_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded.exp) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const timeLeft = decoded.exp - now;
|
||||
if (timeLeft < 300) {
|
||||
console.debug('Token expiring soon:', {
|
||||
timeLeft: `${timeLeft}s`,
|
||||
expireAt: new Date(decoded.exp * 1000).toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
req.user = decoded;
|
||||
req.token = token;
|
||||
|
||||
console.debug('Auth successful:', {
|
||||
userId: decoded._id,
|
||||
username: decoded.username,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return res.status(error.statusCode).json({
|
||||
error: error.message,
|
||||
code: error.code
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
return res.status(401).json({
|
||||
error: '认证失败',
|
||||
code: 'AUTH_FAILED'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 可选认证中间件
|
||||
* 不强制要求认证,但如果提供了有效的 token 则解析用户信息
|
||||
*/
|
||||
export const optionalAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
if (token) {
|
||||
console.debug('Optional auth: token present');
|
||||
const decoded = verifyToken(token);
|
||||
req.user = decoded;
|
||||
req.token = token;
|
||||
} else {
|
||||
console.debug('Optional auth: no token');
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
console.debug('Optional auth failed:', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
code: error instanceof AuthError ? error.code : 'OPTIONAL_AUTH_FAILED'
|
||||
});
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理员认证中间件
|
||||
*/
|
||||
export const adminAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await auth(req, res, () => {
|
||||
if (!req.user || !req.user.isAdmin) {
|
||||
console.debug('Admin auth failed:', {
|
||||
userId: req.user?._id,
|
||||
username: req.user?.username,
|
||||
isAdmin: req.user?.isAdmin
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: '需要管理员权限',
|
||||
code: 'ADMIN_REQUIRED'
|
||||
});
|
||||
}
|
||||
console.debug('Admin auth successful:', {
|
||||
userId: req.user._id,
|
||||
username: req.user.username
|
||||
});
|
||||
next();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin auth unexpected error:', error);
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
code: 'SERVER_ERROR'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 导出类型和错误类
|
||||
export type { UserPayload };
|
||||
export { AuthError };
|
||||
58
server/middleware/practiceValidation.ts
Normal file
58
server/middleware/practiceValidation.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// server/middleware/practiceValidation.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import crypto from 'crypto';
|
||||
export const validatePracticeSubmission = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
const { signature, timestamp, ...practiceData } = req.body;
|
||||
const currentServerTime = Date.now();
|
||||
|
||||
// 1. 验证时间戳
|
||||
if (Math.abs(currentServerTime - timestamp) > 2000) {
|
||||
return res.status(400).json({
|
||||
error: '提交时间异111常',
|
||||
code: 'INVALID_TIMESTAMP'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 使用相同的方式序列化数据
|
||||
const orderedData = JSON.stringify(practiceData, Object.keys(practiceData).sort());
|
||||
|
||||
// 3. 验证签名
|
||||
const expectedSignature = crypto
|
||||
.createHash('sha256')
|
||||
.update(orderedData + timestamp)
|
||||
.digest('hex');
|
||||
|
||||
console.log('Debug info:', {
|
||||
receivedSignature: signature,
|
||||
expectedSignature: expectedSignature,
|
||||
data: orderedData,
|
||||
timestamp: timestamp
|
||||
});
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
return res.status(400).json({
|
||||
error: '数据签名无效',
|
||||
code: 'INVALID_SIGNATURE',
|
||||
debug: {
|
||||
received: signature,
|
||||
expected: expectedSignature
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ... 其他验证代码保持不变
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
res.status(400).json({
|
||||
error: '数据验证失败'+error,
|
||||
code: 'VALIDATION_ERROR'
|
||||
});
|
||||
}
|
||||
};
|
||||
38
server/models/CodeExample.ts
Normal file
38
server/models/CodeExample.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// models/CodeExample.ts
|
||||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
export interface ICodeExample extends Document {
|
||||
title: string;
|
||||
content: string;
|
||||
level: 'basic' | 'intermediate' | 'advanced';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const codeExampleSchema = new Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
level: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['basic', 'intermediate', 'advanced']
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true // 自动管理 createdAt 和 updatedAt
|
||||
});
|
||||
|
||||
export const CodeExample = mongoose.model<ICodeExample>('CodeExample', codeExampleSchema);
|
||||
8
server/models/Keywords.ts
Normal file
8
server/models/Keywords.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const keywordsSchema = new mongoose.Schema({
|
||||
content: String,
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
export const Keywords = mongoose.model('Keywords', keywordsSchema);
|
||||
133
server/models/MinesweeperRecord.ts
Normal file
133
server/models/MinesweeperRecord.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// server/models/MinesweeperRecord.ts
|
||||
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
// 扫雷难度级别
|
||||
export type MinesweeperDifficulty = 'beginner' | 'intermediate' | 'expert' | 'brutal';
|
||||
|
||||
// 扫雷游戏记录接口
|
||||
export interface IMinesweeperRecord extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
difficulty: MinesweeperDifficulty;
|
||||
timeSeconds: number; // 完成时间(秒)
|
||||
won: boolean; // 是否获胜
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 排行榜聚合结果接口
|
||||
export interface IMinesweeperLeaderboardRecord {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
bestTime: number; // 最佳时间
|
||||
totalGames: number; // 总游戏次数
|
||||
wonGames: number; // 获胜次数
|
||||
winRate: number; // 胜率
|
||||
lastPlayed: Date; // 最后游玩时间
|
||||
}
|
||||
|
||||
// 创建 Schema
|
||||
const minesweeperRecordSchema = new Schema<IMinesweeperRecord>({
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
fullname: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['beginner', 'intermediate', 'expert', 'brutal'],
|
||||
required: true
|
||||
},
|
||||
timeSeconds: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
won: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 创建索引以优化查询性能
|
||||
minesweeperRecordSchema.index({ userId: 1, difficulty: 1 });
|
||||
minesweeperRecordSchema.index({ difficulty: 1, won: 1, timeSeconds: 1 });
|
||||
|
||||
// 获取排行榜的静态方法
|
||||
minesweeperRecordSchema.statics.getLeaderboard = function(
|
||||
difficulty: MinesweeperDifficulty,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<IMinesweeperLeaderboardRecord[]> {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
difficulty // 统计所有记录(包括获胜和失败)
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
username: { $first: '$username' },
|
||||
fullname: { $first: '$fullname' },
|
||||
// 最佳时间只统计获胜的记录
|
||||
bestTime: {
|
||||
$min: {
|
||||
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
|
||||
}
|
||||
},
|
||||
totalGames: { $sum: 1 }, // 所有游戏次数(获胜+失败)
|
||||
wonGames: { $sum: { $cond: ['$won', 1, 0] } }, // 获胜次数
|
||||
lastPlayed: { $max: '$createdAt' }
|
||||
}
|
||||
},
|
||||
{
|
||||
// 过滤掉没有获胜记录的用户(因为排行榜需要最佳时间)
|
||||
$match: {
|
||||
bestTime: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userId: '$_id',
|
||||
winRate: {
|
||||
$multiply: [
|
||||
{ $divide: ['$wonGames', '$totalGames'] },
|
||||
100
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $sort: { bestTime: 1 } }, // 按最佳时间升序排序(时间越短越好)
|
||||
{ $skip: skipCount },
|
||||
{ $limit: pageSize }
|
||||
]);
|
||||
};
|
||||
|
||||
// 扩展模型的静态方法类型
|
||||
interface MinesweeperRecordModel extends mongoose.Model<IMinesweeperRecord> {
|
||||
getLeaderboard(
|
||||
difficulty: MinesweeperDifficulty,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<IMinesweeperLeaderboardRecord[]>;
|
||||
}
|
||||
|
||||
// 创建并导出模型
|
||||
export const MinesweeperRecord = mongoose.model<IMinesweeperRecord, MinesweeperRecordModel>(
|
||||
'MinesweeperRecord',
|
||||
minesweeperRecordSchema
|
||||
);
|
||||
163
server/models/PracticeRecord.ts
Normal file
163
server/models/PracticeRecord.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// server/models/PracticeRecord.ts
|
||||
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
// 练习记录的统计数据接口
|
||||
interface IPracticeStats {
|
||||
totalWords: number; // 总单词数
|
||||
correctWords: number; // 正确单词数
|
||||
accuracy: number; // 正确率
|
||||
wordsPerMinute: number; // 每分钟单词数
|
||||
duration: number; // 持续时间(秒)
|
||||
startTime: Date; // 开始时间
|
||||
endTime: Date; // 结束时间
|
||||
}
|
||||
|
||||
// 练习记录接口
|
||||
export interface IPracticeRecord extends Document {
|
||||
userId: mongoose.Types.ObjectId; // 用户ID
|
||||
username: string; // 用户名
|
||||
fullname: string;// 姓名
|
||||
type: 'keyword' | 'basic' | 'intermediate' | 'advanced'; // 练习类型
|
||||
stats: IPracticeStats; // 练习统计数据
|
||||
createdAt: Date; // 记录创建时间
|
||||
updatedAt: Date; // 记录更新时间
|
||||
}
|
||||
// 排行榜聚合结果接口
|
||||
export interface ILeaderboardRecord {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
type: string;
|
||||
stats: {
|
||||
totalWords: number;
|
||||
accuracy: number;
|
||||
wordsPerMinute: number;
|
||||
duration: number;
|
||||
endTime: Date;
|
||||
};
|
||||
score: number;
|
||||
}
|
||||
// 创建 Schema
|
||||
const practiceRecordSchema = new Schema<IPracticeRecord>({
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
fullname: { // 姓名
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['keyword', 'basic', 'intermediate', 'advanced'],
|
||||
required: true
|
||||
},
|
||||
stats: {
|
||||
totalWords: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
correctWords: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
accuracy: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
wordsPerMinute: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
startTime: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
endTime: {
|
||||
type: Date,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timestamps: true // 自动添加 createdAt 和 updatedAt
|
||||
});
|
||||
practiceRecordSchema.statics.getLeaderboard = function(
|
||||
type: string,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ILeaderboardRecord[]> {
|
||||
return this.aggregate([
|
||||
{ $match: { type } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
username: { $first: '$username' },
|
||||
fullname: { $first: '$fullname' },
|
||||
totalDuration: { $sum: '$stats.duration' },
|
||||
avgAccuracy: { $avg: '$stats.accuracy' },
|
||||
totalWords: { $sum: '$stats.totalWords' },
|
||||
avgSpeed: { $avg: '$stats.wordsPerMinute' },
|
||||
lastPractice: { $max: '$stats.endTime' },
|
||||
originalRecord: { $first: '$$ROOT' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
score: {
|
||||
$add: [
|
||||
{ $multiply: [{ $divide: ['$avgAccuracy', 100] }, 40] },
|
||||
{ $multiply: [{ $divide: ['$avgSpeed', 100] }, 30] },
|
||||
{ $multiply: [{ $divide: ['$totalWords', 1000] }, 20] },
|
||||
{ $multiply: [{ $divide: ['$totalDuration', 3600] }, 10] }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $sort: { score: -1 } },
|
||||
{ $skip: skipCount },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$project: {
|
||||
_id: '$originalRecord._id',
|
||||
userId: '$_id',
|
||||
username: 1,
|
||||
fullname: 1,
|
||||
type: '$originalRecord.type',
|
||||
stats: {
|
||||
totalWords: '$totalWords',
|
||||
accuracy: '$avgAccuracy',
|
||||
wordsPerMinute: '$avgSpeed',
|
||||
duration: '$totalDuration',
|
||||
endTime: '$lastPractice'
|
||||
},
|
||||
score: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// 扩展模型的静态方法类型
|
||||
interface PracticeRecordModel extends mongoose.Model<IPracticeRecord> {
|
||||
getLeaderboard(
|
||||
type: string,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ILeaderboardRecord[]>;
|
||||
}
|
||||
|
||||
// 创建并导出模型
|
||||
export const PracticeRecord = mongoose.model<IPracticeRecord, PracticeRecordModel>(
|
||||
'PracticeRecord',
|
||||
practiceRecordSchema
|
||||
);
|
||||
13
server/models/PracticeType.ts
Normal file
13
server/models/PracticeType.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const practiceTypeSchema = new mongoose.Schema({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
level: {
|
||||
type: String,
|
||||
enum: ['basic', 'intermediate', 'advanced', 'keyword'],
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
export const PracticeType = mongoose.model('PracticeType', practiceTypeSchema);
|
||||
121
server/models/SudokuRecord.ts
Normal file
121
server/models/SudokuRecord.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export type SudokuDifficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export interface ISudokuRecord extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
difficulty: SudokuDifficulty;
|
||||
timeSeconds: number;
|
||||
won: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ISudokuLeaderboardRecord {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
bestTime: number;
|
||||
totalGames: number;
|
||||
wonGames: number;
|
||||
winRate: number;
|
||||
lastPlayed: Date;
|
||||
}
|
||||
|
||||
const sudokuRecordSchema = new Schema<ISudokuRecord>({
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
fullname: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'medium', 'hard'],
|
||||
required: true
|
||||
},
|
||||
timeSeconds: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
won: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
sudokuRecordSchema.index({ userId: 1, difficulty: 1 });
|
||||
sudokuRecordSchema.index({ difficulty: 1, won: 1, timeSeconds: 1 });
|
||||
|
||||
sudokuRecordSchema.statics.getLeaderboard = function(
|
||||
difficulty: SudokuDifficulty,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
difficulty
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
username: { $first: '$username' },
|
||||
fullname: { $first: '$fullname' },
|
||||
bestTime: {
|
||||
$min: {
|
||||
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
|
||||
}
|
||||
},
|
||||
totalGames: { $sum: 1 },
|
||||
wonGames: { $sum: { $cond: ['$won', 1, 0] } },
|
||||
lastPlayed: { $max: '$createdAt' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
bestTime: { $ne: null }
|
||||
}
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userId: '$_id',
|
||||
winRate: {
|
||||
$multiply: [
|
||||
{ $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] },
|
||||
100
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $sort: { bestTime: 1 } },
|
||||
{ $skip: skipCount },
|
||||
{ $limit: pageSize }
|
||||
]);
|
||||
};
|
||||
|
||||
interface SudokuRecordModel extends mongoose.Model<ISudokuRecord> {
|
||||
getLeaderboard(
|
||||
difficulty: SudokuDifficulty,
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ISudokuLeaderboardRecord[]>;
|
||||
}
|
||||
|
||||
export const SudokuRecord = mongoose.model<ISudokuRecord, SudokuRecordModel>(
|
||||
'SudokuRecord',
|
||||
sudokuRecordSchema
|
||||
);
|
||||
102
server/models/TowerDefenseRecord.ts
Normal file
102
server/models/TowerDefenseRecord.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// server/models/TowerDefenseRecord.ts
|
||||
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface ITowerDefenseRecord extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
wave: number; // 到达的波次
|
||||
score: number; // 最终积分
|
||||
timeSeconds: number; // 游戏持续时间(秒)
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ITowerDefenseLeaderboardRecord {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
username: string;
|
||||
fullname: string;
|
||||
bestWave: number; // 最高波次
|
||||
bestScore: number; // 最高积分
|
||||
totalGames: number; // 总游戏次数
|
||||
lastPlayed: Date; // 最后游玩时间
|
||||
}
|
||||
|
||||
const towerDefenseRecordSchema = new Schema<ITowerDefenseRecord>({
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
fullname: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
wave: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
score: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
timeSeconds: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 索引优化
|
||||
towerDefenseRecordSchema.index({ userId: 1, score: -1 });
|
||||
towerDefenseRecordSchema.index({ score: -1 });
|
||||
towerDefenseRecordSchema.index({ wave: -1 });
|
||||
|
||||
// 获取排行榜的静态方法
|
||||
towerDefenseRecordSchema.statics.getLeaderboard = function(
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ITowerDefenseLeaderboardRecord[]> {
|
||||
return this.aggregate([
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
username: { $first: '$username' },
|
||||
fullname: { $first: '$fullname' },
|
||||
bestWave: { $max: '$wave' },
|
||||
bestScore: { $max: '$score' },
|
||||
totalGames: { $sum: 1 },
|
||||
lastPlayed: { $max: '$createdAt' }
|
||||
}
|
||||
},
|
||||
{ $sort: { bestScore: -1 } }, // 优先按积分排
|
||||
{ $skip: skipCount },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$addFields: {
|
||||
userId: '$_id'
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
interface TowerDefenseRecordModel extends mongoose.Model<ITowerDefenseRecord> {
|
||||
getLeaderboard(
|
||||
skipCount: number,
|
||||
pageSize: number
|
||||
): mongoose.Aggregate<ITowerDefenseLeaderboardRecord[]>;
|
||||
}
|
||||
|
||||
export const TowerDefenseRecord = mongoose.model<ITowerDefenseRecord, TowerDefenseRecordModel>(
|
||||
'TowerDefenseRecord',
|
||||
towerDefenseRecordSchema
|
||||
);
|
||||
36
server/models/TowerDefenseSave.ts
Normal file
36
server/models/TowerDefenseSave.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// server/models/TowerDefenseSave.ts
|
||||
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface ITowerDefenseSave extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
name: string; // e.g. timestamp string
|
||||
state: any; // arbitrary game state JSON
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const towerDefenseSaveSchema = new Schema<ITowerDefenseSave>({
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
state: {
|
||||
type: Schema.Types.Mixed,
|
||||
required: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
towerDefenseSaveSchema.index({ userId: 1, createdAt: -1 });
|
||||
|
||||
export const TowerDefenseSave = mongoose.model<ITowerDefenseSave>(
|
||||
'TowerDefenseSave',
|
||||
towerDefenseSaveSchema
|
||||
);
|
||||
329
server/models/User.ts
Normal file
329
server/models/User.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
// server/models/User.ts
|
||||
import mongoose, { Document } from 'mongoose';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { log } from 'console';
|
||||
// 用户统计信息接口
|
||||
export interface UserStats {
|
||||
totalPracticeCount: number; // 练习总次数
|
||||
totalWords: number; // 练习单词总数
|
||||
totalAccuracy: number; // 累计正确率(用于计算平均值)
|
||||
accuracyHistory: number[]; // 正确率历史记录
|
||||
todayPracticeTime: number; // 今日练习时长(秒)
|
||||
lastPracticeDate: Date; // 最后练习日期
|
||||
totalSpeed: number; // 累计速度,用于计算平均值
|
||||
}
|
||||
|
||||
// 扩展用户接口,包含统计信息
|
||||
export interface IUser extends Document {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
fullname: string;
|
||||
isAdmin: boolean;
|
||||
stats: UserStats;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// 方法
|
||||
resetTodayPracticeTime(): Promise<void>;
|
||||
updatePracticeStats(data: { words: number; accuracy: number; duration: number; speed: number }): Promise<void>;
|
||||
getAccuracyTrend(limit?: number): number[];
|
||||
comparePassword(candidatePassword: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
const userStatsSchema = new mongoose.Schema({
|
||||
totalPracticeCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
validate: {
|
||||
validator: Number.isInteger,
|
||||
message: '练习次数必须是整数'
|
||||
}
|
||||
},
|
||||
totalWords: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
validate: {
|
||||
validator: Number.isInteger,
|
||||
message: '单词总数必须是整数'
|
||||
}
|
||||
},
|
||||
totalAccuracy: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 100 * 1000000, // 假设最多100万次练习,每次100%
|
||||
validate: {
|
||||
validator: Number.isFinite,
|
||||
message: '累计正确率必须是有限数字'
|
||||
}
|
||||
},
|
||||
accuracyHistory: {
|
||||
type: [Number],
|
||||
default: [],
|
||||
validate: {
|
||||
validator: function (v: number[]) {
|
||||
return v.every(rate => rate >= 0 && rate <= 100);
|
||||
},
|
||||
message: '正确率必须在0到100之间'
|
||||
}
|
||||
},
|
||||
todayPracticeTime: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
validate: {
|
||||
validator: Number.isInteger,
|
||||
message: '练习时长必须是整数'
|
||||
}
|
||||
},
|
||||
lastPracticeDate: {
|
||||
type: Date,
|
||||
default: () => new Date()
|
||||
},
|
||||
totalSpeed: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0,
|
||||
validate: {
|
||||
validator: Number.isFinite,
|
||||
message: '速度必须是有效数字'
|
||||
}
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
username: {
|
||||
type: String,
|
||||
required: [true, '用户名是必需的'],
|
||||
unique: true,
|
||||
trim: true,
|
||||
minlength: [2, '用户名至少需要2个字符'],
|
||||
maxlength: [20, '用户名最多20个字符']
|
||||
},
|
||||
fullname: {
|
||||
type: String,
|
||||
required: [true, '姓名是必需的'],
|
||||
trim: true,
|
||||
minlength: [2, '姓名至少需要2个字符'],
|
||||
maxlength: [50, '姓名最多50个字符']
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: [true, '密码是必需的'],
|
||||
minlength: [6, '密码至少需要6个字符']
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: [true, '邮箱是必需的'],
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, '请输入有效的邮箱地址']
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
stats: {
|
||||
type: userStatsSchema,
|
||||
default: () => ({
|
||||
totalPracticeCount: 0,
|
||||
totalWords: 0,
|
||||
totalAccuracy: 0,
|
||||
totalSpeed: 0,
|
||||
accuracyHistory: [],
|
||||
todayPracticeTime: 0,
|
||||
lastPracticeDate: new Date()
|
||||
})
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// 中间件
|
||||
userSchema.pre('save', function (next) {
|
||||
if (!this.stats) {
|
||||
this.stats = {
|
||||
totalPracticeCount: 0,
|
||||
totalWords: 0,
|
||||
totalAccuracy: 0,
|
||||
totalSpeed: 0,
|
||||
accuracyHistory: [],
|
||||
todayPracticeTime: 0,
|
||||
lastPracticeDate: new Date()
|
||||
};
|
||||
}
|
||||
next();
|
||||
});
|
||||
userSchema.pre('save', async function(next) {
|
||||
const user = this;
|
||||
|
||||
// 只有在密码被修改时才进行加密
|
||||
if (!user.isModified('password')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
// 生成盐值并加密密码
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(user.password, salt);
|
||||
user.password = hashedPassword;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error as Error);
|
||||
}
|
||||
});
|
||||
userSchema.methods.comparePassword = async function(candidatePassword: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await bcrypt.compare(candidatePassword, this.password);
|
||||
|
||||
|
||||
// 添加详细的调试信息
|
||||
console.log('Password Comparison Details:', {
|
||||
input: candidatePassword,
|
||||
storedHash: this.password,
|
||||
result: result
|
||||
});
|
||||
|
||||
const password = String(202410121);
|
||||
const rounds = 10;
|
||||
|
||||
// 生成盐值和hash
|
||||
const salt1 = await bcrypt.genSalt(rounds);
|
||||
const hash1 = await bcrypt.hash(password, salt1);
|
||||
|
||||
const salt2 = await bcrypt.genSalt(rounds);
|
||||
const hash2 = await bcrypt.hash(password, salt2);
|
||||
|
||||
console.log('Test results:', {
|
||||
password,
|
||||
hash1,
|
||||
hash2,
|
||||
compareResult1: await bcrypt.compare('202410121', hash1),
|
||||
compareResult2: await bcrypt.compare(password, hash2)
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Password comparison error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
userSchema.pre('findOneAndUpdate', function (next) {
|
||||
const update = this.getUpdate() as any;
|
||||
if (update && !update.stats) {
|
||||
update.stats = {
|
||||
totalPracticeCount: 0,
|
||||
totalWords: 0,
|
||||
totalAccuracy: 0,
|
||||
totalSpeed: 0,
|
||||
accuracyHistory: [],
|
||||
todayPracticeTime: 0,
|
||||
lastPracticeDate: new Date()
|
||||
};
|
||||
}
|
||||
next();
|
||||
});
|
||||
async function handlePasswordUpdate(this: any, next: Function) {
|
||||
const update = this.getUpdate() as any;
|
||||
|
||||
if (update && (update.password || (update.$set && update.$set.password))) {
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
|
||||
if (update.password) {
|
||||
update.password = await bcrypt.hash(update.password, salt);
|
||||
} else if (update.$set && update.$set.password) {
|
||||
update.$set.password = await bcrypt.hash(update.$set.password, salt);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error as Error);
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个操作添加中间件,使用相同的处理函数
|
||||
userSchema.pre('findOneAndUpdate', handlePasswordUpdate);
|
||||
// 虚拟属性
|
||||
userSchema.virtual('averageAccuracy').get(function (this: IUser) {
|
||||
if (this.stats.totalPracticeCount === 0) return 0;
|
||||
return this.stats.totalAccuracy / this.stats.totalPracticeCount;
|
||||
});
|
||||
|
||||
userSchema.virtual('averageSpeed').get(function(this: IUser) {
|
||||
if (this.stats.totalPracticeCount === 0) return 0;
|
||||
return this.stats.totalSpeed / this.stats.totalPracticeCount;
|
||||
});
|
||||
|
||||
// 方法
|
||||
userSchema.methods.resetTodayPracticeTime = async function (this: IUser) {
|
||||
this.stats.todayPracticeTime = 0;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
userSchema.methods.updatePracticeStats = async function (this: IUser, {
|
||||
words,
|
||||
accuracy,
|
||||
duration,
|
||||
speed
|
||||
}: {
|
||||
words: number;
|
||||
accuracy: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
}) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (!this.stats.lastPracticeDate || this.stats.lastPracticeDate < today) {
|
||||
this.stats.todayPracticeTime = 0;
|
||||
}
|
||||
|
||||
this.stats.totalPracticeCount += 1;
|
||||
this.stats.totalWords += words;
|
||||
this.stats.totalAccuracy =
|
||||
((this.stats.totalAccuracy * (this.stats.totalPracticeCount - 1)) + accuracy)
|
||||
/ this.stats.totalPracticeCount;
|
||||
this.stats.accuracyHistory.push(accuracy);
|
||||
if (this.stats.accuracyHistory.length > 10) {
|
||||
this.stats.accuracyHistory.shift(); // 保持最近10次记录
|
||||
}
|
||||
this.stats.todayPracticeTime += Math.round(duration);
|
||||
this.stats.lastPracticeDate = new Date();
|
||||
this.stats.totalSpeed =
|
||||
((this.stats.totalSpeed * (this.stats.totalPracticeCount - 1)) + speed)
|
||||
/ this.stats.totalPracticeCount;
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
userSchema.methods.getAccuracyTrend = function (this: IUser, limit = 10): number[] {
|
||||
return this.stats.accuracyHistory.slice(-limit);
|
||||
};
|
||||
|
||||
// 索引
|
||||
userSchema.index({ username: 1 }, { unique: true });
|
||||
userSchema.index({ email: 1 }, { unique: true });
|
||||
userSchema.index({ 'stats.lastPracticeDate': 1 });
|
||||
|
||||
// 错误处理
|
||||
userSchema.post('save', function (error: any, doc: any, next: any) {
|
||||
if (error.name === 'MongoError' || error.name === 'MongoServerError') {
|
||||
if (error.code === 11000) {
|
||||
next(new Error('用户名或邮箱已存在'));
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 创建模型并导出
|
||||
export const User = mongoose.model<IUser>('User', userSchema);
|
||||
137
server/models/Vocabulary.ts
Normal file
137
server/models/Vocabulary.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
// 单词接口
|
||||
export interface IWord extends Document {
|
||||
word: string;
|
||||
translation: string;
|
||||
pronunciation?: string;
|
||||
example?: string;
|
||||
wordSet: mongoose.Types.ObjectId;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 单词集接口
|
||||
export interface IWordSet extends Document {
|
||||
name: string;
|
||||
description?: string;
|
||||
owner: mongoose.Types.ObjectId;
|
||||
totalWords: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 单词学习记录接口
|
||||
export interface IWordRecord extends Document {
|
||||
user: mongoose.Types.ObjectId;
|
||||
word: mongoose.Types.ObjectId;
|
||||
mode: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||
streak: number;
|
||||
totalCorrect: number;
|
||||
totalWrong: number;
|
||||
mastered: boolean;
|
||||
inWrongBook: boolean;
|
||||
lastTestedAt: Date;
|
||||
lastMasteredAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// 单词测试记录接口
|
||||
export interface IVocabularyTestRecord extends Document {
|
||||
user: mongoose.Types.ObjectId;
|
||||
wordSet: mongoose.Types.ObjectId;
|
||||
testType: 'chinese-to-english' | 'audio-to-english' | 'multiple-choice';
|
||||
stats: {
|
||||
totalWords: number;
|
||||
correctWords: number;
|
||||
accuracy: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
duration: number;
|
||||
};
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// 单词模式
|
||||
const WordSchema = new Schema<IWord>({
|
||||
word: { type: String, required: true, trim: true },
|
||||
translation: { type: String, required: true, trim: true },
|
||||
pronunciation: { type: String, trim: true },
|
||||
example: { type: String, trim: true },
|
||||
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
||||
}, { timestamps: true });
|
||||
|
||||
// 为单词模型添加索引
|
||||
WordSchema.index({ word: 1, wordSet: 1 }, { unique: true });
|
||||
WordSchema.index({ translation: 'text', word: 'text' });
|
||||
|
||||
// 单词集模式
|
||||
const WordSetSchema = new Schema<IWordSet>({
|
||||
name: { type: String, required: true, trim: true },
|
||||
description: { type: String, trim: true },
|
||||
owner: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
totalWords: { type: Number, default: 0 },
|
||||
}, { timestamps: true });
|
||||
|
||||
// 为单词集模型添加索引
|
||||
WordSetSchema.index({ name: 1, owner: 1 }, { unique: true });
|
||||
|
||||
// 简化的嵌套模式统计
|
||||
const ModeStatsSchema = new Schema({
|
||||
streak: { type: Number, default: 0 },
|
||||
totalCorrect: { type: Number, default: 0 },
|
||||
totalWrong: { type: Number, default: 0 },
|
||||
mastered: { type: Boolean, default: false },
|
||||
inWrongBook: { type: Boolean, default: false },
|
||||
lastTestedAt: Date
|
||||
}, { _id: false });
|
||||
|
||||
// 简化后的主记录模式
|
||||
const WordRecordSchema = new Schema({
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
word: { type: Schema.Types.ObjectId, ref: 'Word', required: true },
|
||||
|
||||
// 各模式的基础统计数据
|
||||
multipleChoice: { type: ModeStatsSchema, default: () => ({}) },
|
||||
audioToEnglish: { type: ModeStatsSchema, default: () => ({}) },
|
||||
chineseToEnglish: { type: ModeStatsSchema, default: () => ({}) },
|
||||
|
||||
// 只在顶层保留整体掌握状态
|
||||
isFullyMastered: { type: Boolean, default: false },
|
||||
lastFullyMasteredAt: Date,
|
||||
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
// 单词测试记录模式
|
||||
const VocabularyTestRecordSchema = new Schema<IVocabularyTestRecord>({
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
wordSet: { type: Schema.Types.ObjectId, ref: 'WordSet', required: true },
|
||||
testType: {
|
||||
type: String,
|
||||
enum: ['chinese-to-english', 'audio-to-english', 'multiple-choice'],
|
||||
required: true
|
||||
},
|
||||
stats: {
|
||||
totalWords: { type: Number, required: true },
|
||||
correctWords: { type: Number, required: true },
|
||||
accuracy: { type: Number, required: true },
|
||||
startTime: { type: Date, required: true },
|
||||
endTime: { type: Date, required: true },
|
||||
duration: { type: Number, required: true }
|
||||
},
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
});
|
||||
|
||||
// 创建和导出模型
|
||||
export const Word = mongoose.model<IWord>('Word', WordSchema);
|
||||
export const WordSet = mongoose.model<IWordSet>('WordSet', WordSetSchema);
|
||||
export const WordRecord = mongoose.model('WordRecord', WordRecordSchema);
|
||||
export const VocabularyTestRecord = mongoose.model<IVocabularyTestRecord>('VocabularyTestRecord', VocabularyTestRecordSchema);
|
||||
|
||||
export default {
|
||||
Word,
|
||||
WordSet,
|
||||
WordRecord,
|
||||
VocabularyTestRecord
|
||||
};
|
||||
42
server/models/oauth2.ts
Normal file
42
server/models/oauth2.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Schema, model } from 'mongoose';
|
||||
|
||||
// OAuth2客户端应用
|
||||
const OAuth2ClientSchema = new Schema({
|
||||
clientId: { type: String, required: true, unique: true },
|
||||
clientSecret: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
redirectUris: [{ type: String }],
|
||||
grants: [{ type: String }],
|
||||
scope: [{ type: String }],
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
linkedUsers: [{
|
||||
userId: String,
|
||||
username: String,
|
||||
email: String
|
||||
}]
|
||||
});
|
||||
|
||||
// OAuth2授权码
|
||||
const OAuth2AuthorizationCodeSchema = new Schema({
|
||||
code: { type: String, required: true, unique: true },
|
||||
clientId: { type: String, required: true },
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
scope: [{ type: String }],
|
||||
redirectUri: { type: String, required: true },
|
||||
expiresAt: { type: Date, required: true },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
});
|
||||
|
||||
// OAuth2访问令牌
|
||||
const OAuth2AccessTokenSchema = new Schema({
|
||||
accessToken: { type: String, required: true, unique: true },
|
||||
clientId: { type: String, required: true },
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
scope: [{ type: String }],
|
||||
expiresAt: { type: Date, required: true },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
});
|
||||
|
||||
export const OAuth2Client = model('OAuth2Client', OAuth2ClientSchema);
|
||||
export const OAuth2AuthorizationCode = model('OAuth2AuthorizationCode', OAuth2AuthorizationCodeSchema);
|
||||
export const OAuth2AccessToken = model('OAuth2AccessToken', OAuth2AccessTokenSchema);
|
||||
3295
server/package-lock.json
generated
Normal file
3295
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
server/package.json
Normal file
33
server/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node --transpile-only server.ts",
|
||||
"debug": "node --inspect=5858 -r ts-node/register server.ts",
|
||||
"dev": "nodemon -r ts-node/register server.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"connect-mongo": "^5.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"express": "^4.21.1",
|
||||
"express-session": "^1.18.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.7.3",
|
||||
"multer": "^1.4.5-lts.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express-session": "^1.18.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
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;
|
||||
38
server/scripts/seedPracticeTypes.ts
Normal file
38
server/scripts/seedPracticeTypes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { PracticeType } from '../models/PracticeType';
|
||||
|
||||
const seedData = [
|
||||
{
|
||||
title: '关键字训练',
|
||||
description: '训练C/C++基础关键字的输入',
|
||||
level: 'keyword',
|
||||
},
|
||||
{
|
||||
title: '初级算法',
|
||||
description: '训练基础算法代码的输入',
|
||||
level: 'basic',
|
||||
},
|
||||
{
|
||||
title: '中级算法',
|
||||
description: '训练中级算法代码的输入',
|
||||
level: 'intermediate',
|
||||
},
|
||||
{
|
||||
title: '高级算法',
|
||||
description: '训练高级算法代码的输入',
|
||||
level: 'advanced',
|
||||
},
|
||||
];
|
||||
|
||||
const seedPracticeTypes = async () => {
|
||||
try {
|
||||
await PracticeType.deleteMany({});
|
||||
await PracticeType.insertMany(seedData);
|
||||
console.log('Practice types seeded successfully');
|
||||
} catch (error) {
|
||||
console.error('Error seeding practice types:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 运行种子脚本
|
||||
seedPracticeTypes();
|
||||
221
server/server.ts
Normal file
221
server/server.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import cors from 'cors';
|
||||
import session from 'express-session';
|
||||
import MongoStore from 'connect-mongo';
|
||||
import path from 'path';
|
||||
import { createServer } from 'http';
|
||||
import { setupMinesweeperSocket } from './websocket/minesweeperSocket';
|
||||
import practiceTypesRouter from './routes/practiceTypes';
|
||||
import codeExamplesRouter from './routes/codeExamples';
|
||||
import authRouter from './routes/auth';
|
||||
import keywordsRouter from './routes/keywords';
|
||||
import practiceRecordsRouter from './routes/practiceRecords';
|
||||
import leaderboardRouter from './routes/leaderboard';
|
||||
import adminRoutes from './routes/admin';
|
||||
import systemRoutes from './routes/system';
|
||||
import oauth2Routes from './routes/oauth2.routes';
|
||||
import visitorRoutes from './routes/visitor';
|
||||
import vocabularyRoutes from './routes/vocabulary';
|
||||
import userWordPassRouter from './routes/userWordPass';
|
||||
import minesweeperRouter from './routes/minesweeper';
|
||||
import towerDefenseRouter from './routes/towerDefense';
|
||||
import sudokuRouter from './routes/sudoku';
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
|
||||
// 中间件配置
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cors());
|
||||
|
||||
// 静态文件服务 - 优先处理 public 目录下的静态文件
|
||||
const publicPath = path.join(__dirname, '../public');
|
||||
const buildPath = path.join(__dirname, '../build');
|
||||
console.log('Static paths configured:');
|
||||
console.log(' Public path:', publicPath);
|
||||
console.log(' Build path:', buildPath);
|
||||
|
||||
app.use(express.static(publicPath));
|
||||
app.use(express.static(buildPath));
|
||||
|
||||
// 挂载塔防游戏静态目录 (使用源码中的 src 目录)
|
||||
const towerDefenseStaticPath = path.join(__dirname, '../html5-tower-defense-master/src');
|
||||
console.log('Tower Defense static path:', towerDefenseStaticPath);
|
||||
app.use('/tower-defense', express.static(towerDefenseStaticPath));
|
||||
app.get('/tower-defense', (req, res) => {
|
||||
res.sendFile(path.join(towerDefenseStaticPath, 'td.html'));
|
||||
});
|
||||
|
||||
// MongoDB 连接
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/typeskill';
|
||||
|
||||
// 添加 session 配置
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'your-secret-key',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: MongoStore.create({
|
||||
mongoUrl: MONGODB_URI,
|
||||
ttl: 24 * 60 * 60
|
||||
}),
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
}));
|
||||
|
||||
// 如果使用了代理(如 nginx),添加这行
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
mongoose.connect(MONGODB_URI)
|
||||
.then(() => {
|
||||
console.log('Connected to MongoDB');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('MongoDB connection error:', error);
|
||||
});
|
||||
|
||||
// 路由配置
|
||||
// 添加请求日志中间件 - 在所有路由之前
|
||||
app.use((req, res, next) => {
|
||||
console.log('=== Incoming Request ===');
|
||||
console.log('Time:', new Date().toISOString());
|
||||
console.log('Method:', req.method);
|
||||
console.log('Path:', req.path);
|
||||
console.log('Original URL:', req.originalUrl);
|
||||
console.log('Headers:', JSON.stringify(req.headers, null, 2));
|
||||
console.log('========================');
|
||||
next();
|
||||
});
|
||||
|
||||
// 特殊静态 HTML 文件路由 - 在 API 路由之前处理
|
||||
app.get('/xf/xf.html', (req, res) => {
|
||||
const filePath = path.join(__dirname, '../public/xf/xf.html');
|
||||
console.log('>>> Serving xf.html');
|
||||
console.log(' File path:', filePath);
|
||||
console.log(' __dirname:', __dirname);
|
||||
|
||||
// 检查文件是否存在
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(' File exists: YES');
|
||||
res.sendFile(filePath);
|
||||
} else {
|
||||
console.log(' File exists: NO');
|
||||
res.status(404).send('File not found: ' + filePath);
|
||||
}
|
||||
});
|
||||
|
||||
// 通用静态 HTML 处理 - 处理 /xf/ 目录下的其他文件
|
||||
app.get('/xf/*', (req, res, next) => {
|
||||
const filePath = path.join(__dirname, '../public', req.path);
|
||||
console.log('>>> Serving /xf/* file');
|
||||
console.log(' Requested path:', req.path);
|
||||
console.log(' Full file path:', filePath);
|
||||
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(' File exists: YES');
|
||||
res.sendFile(filePath);
|
||||
} else {
|
||||
console.log(' File exists: NO, passing to next middleware');
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// OIDC Discovery 路由
|
||||
app.get('/.well-known/openid-configuration', (req, res) => {
|
||||
const issuer = process.env.ISSUER || 'https://d1kt.cn'; // 你的主域名
|
||||
res.json({
|
||||
issuer,
|
||||
authorization_endpoint: issuer + '/api/api/oauth2/authorize',
|
||||
token_endpoint: issuer + '/api/api/oauth2/token',
|
||||
userinfo_endpoint: issuer + '/api/api/oauth2/userinfo',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['HS256'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'firstname', 'lastname', 'username'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
claims_supported: ['sub', 'name', 'fullname', 'email', 'firstname', 'lastname', 'username'],
|
||||
});
|
||||
});
|
||||
app.use('/api/practice-types', practiceTypesRouter);
|
||||
app.use('/api/code-examples', codeExamplesRouter);
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/oauth2', oauth2Routes);
|
||||
app.use('/api/keywords', keywordsRouter);
|
||||
app.use('/api/practice-records', practiceRecordsRouter);
|
||||
app.use('/api/leaderboard', leaderboardRouter);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/system', systemRoutes);
|
||||
app.use('/api/visitor', visitorRoutes);
|
||||
app.use('/api/vocabulary', vocabularyRoutes);
|
||||
app.use('/api', userWordPassRouter);
|
||||
app.use('/api/minesweeper', minesweeperRouter);
|
||||
app.use('/api/tower-defense', towerDefenseRouter);
|
||||
app.use('/api/sudoku', sudokuRouter);
|
||||
|
||||
|
||||
// SPA 回退路由 - 必须放在所有路由之后
|
||||
app.get('*', (req, res, next) => {
|
||||
console.log('>>> SPA Fallback triggered');
|
||||
console.log(' Path:', req.path);
|
||||
console.log(' Starts with /api/:', req.path.startsWith('/api/'));
|
||||
console.log(' Includes dot:', req.path.includes('.'));
|
||||
|
||||
// 如果是 API 请求或静态资源,跳过
|
||||
if (req.path.startsWith('/api/') || req.path.includes('.')) {
|
||||
console.log(' Action: Passing to next middleware');
|
||||
return next();
|
||||
}
|
||||
// 否则返回 React 应用的 index.html
|
||||
console.log(' Action: Returning React index.html');
|
||||
res.sendFile(path.join(__dirname, '../build/index.html'));
|
||||
});
|
||||
|
||||
// 响应结束日志
|
||||
app.use((req, res, next) => {
|
||||
const originalSend = res.send;
|
||||
const originalSendFile = res.sendFile;
|
||||
|
||||
res.send = function(data) {
|
||||
console.log('>>> Response sent (send method)');
|
||||
console.log(' Status:', res.statusCode);
|
||||
console.log(' Data length:', typeof data === 'string' ? data.length : 'N/A');
|
||||
return originalSend.apply(res, arguments);
|
||||
};
|
||||
|
||||
res.sendFile = function(filePath) {
|
||||
console.log('>>> Response sent (sendFile method)');
|
||||
console.log(' Status:', res.statusCode);
|
||||
console.log(' File:', filePath);
|
||||
return originalSendFile.apply(res, arguments);
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).send('Something broke!');
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5001;
|
||||
const httpServer = createServer(app);
|
||||
|
||||
// 设置 WebSocket
|
||||
setupMinesweeperSocket(httpServer);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`HTTP Server address:`, httpServer.address());
|
||||
console.log(`WebSocket server is ready`);
|
||||
console.log(`Environment:`, process.env.NODE_ENV);
|
||||
console.log(`REACT_APP_API_BASE_URL:`, process.env.REACT_APP_API_BASE_URL);
|
||||
});
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"./**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
13
server/utils/crypto.ts
Normal file
13
server/utils/crypto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成指定长度的随机字符串
|
||||
* @param length 要生成的字符串长度
|
||||
* @returns 随机字符串
|
||||
*/
|
||||
export function generateRandomString(length: number): string {
|
||||
return crypto
|
||||
.randomBytes(Math.ceil(length / 2))
|
||||
.toString('hex')
|
||||
.slice(0, length);
|
||||
}
|
||||
416
server/websocket/minesweeperSocket.ts
Normal file
416
server/websocket/minesweeperSocket.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
// server/websocket/minesweeperSocket.ts
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config();
|
||||
|
||||
interface Cell {
|
||||
isMine: boolean;
|
||||
isRevealed: boolean;
|
||||
isFlagged: boolean;
|
||||
neighborMines: number;
|
||||
isExploded?: boolean;
|
||||
}
|
||||
|
||||
interface GameRoom {
|
||||
roomId: string;
|
||||
playerId: string;
|
||||
board: Cell[][];
|
||||
difficulty: string;
|
||||
spectators: Set<string>;
|
||||
highlightedCells: Set<string>; // 存储需要闪烁的格子坐标 "row,col"
|
||||
invitePlayMode: boolean; // 是否邀请同玩模式
|
||||
}
|
||||
|
||||
// 存储所有游戏房间
|
||||
const gameRooms = new Map<string, GameRoom>();
|
||||
|
||||
// 获取 WebSocket 路径前缀 - 与客户端保持一致
|
||||
function getSocketIoPath() {
|
||||
const envApiUrl = process.env.REACT_APP_API_BASE_URL;
|
||||
|
||||
console.log('[Socket.IO Path Config] REACT_APP_API_BASE_URL:', envApiUrl);
|
||||
console.log('[Socket.IO Path Config] NODE_ENV:', process.env.NODE_ENV);
|
||||
|
||||
if (!envApiUrl || envApiUrl.trim() === '') {
|
||||
// 如果没有设置环境变量,根据 NODE_ENV 决定
|
||||
const path = process.env.NODE_ENV === 'production' ? '/api/socket.io' : '/socket.io';
|
||||
console.log('[Socket.IO Path Config] Using default path:', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
// 根据Nginx转发规则调整生产环境路径
|
||||
// Nginx转发到 http://localhost:5001/api/socket.io/,所以后端应该接受 /api/socket.io
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('[Socket.IO Path Config] Production mode, Nginx配置');
|
||||
console.log('[Socket.IO Path Config] Frontend sends: /api/api/socket.io');
|
||||
console.log('[Socket.IO Path Config] Nginx location = /api/api/socket.io/');
|
||||
console.log('[Socket.IO Path Config] Nginx proxy_pass: http://localhost:5001/api/socket.io/');
|
||||
console.log('[Socket.IO Path Config] Backend should accept: /api/socket.io');
|
||||
// 使用 /api/socket.io 路径匹配Nginx转发结果
|
||||
return '/api/socket.io';
|
||||
}
|
||||
|
||||
// 如果是完整 URL,检查是否包含路径
|
||||
if (envApiUrl.startsWith('http://') || envApiUrl.startsWith('https://')) {
|
||||
const urlObj = new URL(envApiUrl);
|
||||
// 如果原始 URL 包含路径,将其作为前缀
|
||||
if (urlObj.pathname && urlObj.pathname !== '/') {
|
||||
const path = `${urlObj.pathname}/socket.io`;
|
||||
console.log('[Socket.IO Path Config] From URL pathname:', path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是相对路径(如 /api),将其作为前缀
|
||||
if (envApiUrl.startsWith('/')) {
|
||||
const path = `${envApiUrl}/socket.io`;
|
||||
console.log('[Socket.IO Path Config] From relative path:', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
const path = '/socket.io';
|
||||
console.log('[Socket.IO Path Config] Using fallback path:', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function setupMinesweeperSocket(httpServer: HTTPServer) {
|
||||
// 生产环境支持多种可能的路径,通过请求检测来确定
|
||||
const possiblePaths = ['/api/socket.io', '/socket.io'];
|
||||
const socketIoPath = getSocketIoPath();
|
||||
console.log('[Socket.IO] Socket.IO 路径配置:', socketIoPath);
|
||||
console.log('[Socket.IO] 支持的可能路径:', possiblePaths);
|
||||
console.log('[Socket.IO] HTTP Server listening on port:', httpServer.address());
|
||||
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
path: socketIoPath, // 使用最常见的路径作为配置
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
},
|
||||
allowEIO3: true,
|
||||
transports: ['polling', 'websocket']
|
||||
});
|
||||
|
||||
// 添加底层HTTP请求日志中间件
|
||||
io.engine.on('initial_headers', (headers, req) => {
|
||||
console.log('[Socket.IO Engine] === INITIAL HEADERS ===');
|
||||
console.log('[Socket.IO Engine] Method:', req.method);
|
||||
console.log('[Socket.IO Engine] URL:', req.url);
|
||||
console.log('[Socket.IO Engine] Headers:', JSON.stringify(req.headers, null, 2));
|
||||
console.log('[Socket.IO Engine] Query:', req.url ? req.url.split('?')[1] : 'N/A');
|
||||
console.log('[Socket.IO Engine] ========================');
|
||||
});
|
||||
|
||||
io.engine.on('headers', (headers, req) => {
|
||||
console.log('[Socket.IO Engine] === RESPONSE HEADERS ===');
|
||||
console.log('[Socket.IO Engine] Status:', headers.status || 200);
|
||||
console.log('[Socket.IO Engine] Headers:', JSON.stringify(headers, null, 2));
|
||||
console.log('[Socket.IO Engine] ========================');
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('[Socket.IO] >>> CONNECTION ESTABLISHED <<<');
|
||||
console.log('[Socket.IO] 用户连接:', socket.id);
|
||||
console.log('[Socket.IO] 握手耗时:', Date.now() - (socket.handshake.time || Date.now()), 'ms');
|
||||
console.log('[Socket.IO] 握手 URL:', socket.handshake.url);
|
||||
console.log('[Socket.IO] 握手查询参数:', socket.handshake.query);
|
||||
console.log('[Socket.IO] 握手 Origin:', socket.handshake.headers.origin);
|
||||
console.log('[Socket.IO] 握手 Referer:', socket.handshake.headers.referer);
|
||||
console.log('[Socket.IO] 握手 User-Agent:', socket.handshake.headers['user-agent']);
|
||||
console.log('[Socket.IO] 握手 Host:', socket.handshake.headers.host);
|
||||
console.log('[Socket.IO] 配置的 Socket.IO 路径:', socketIoPath);
|
||||
|
||||
// 详细路径分析 - 规范化路径比较(处理末尾斜杠差异)
|
||||
const reqPath = socket.handshake.url ? socket.handshake.url.split('?')[0] : 'N/A';
|
||||
const normalizedReqPath = reqPath.replace(/\/$/, ''); // 移除末尾斜杠
|
||||
const normalizedConfigPath = socketIoPath.replace(/\/$/, ''); // 移除末尾斜杠
|
||||
|
||||
console.log('[Socket.IO] 路径分析:');
|
||||
console.log(' - 原始请求路径:', reqPath);
|
||||
console.log(' - 规范化请求路径:', normalizedReqPath);
|
||||
console.log(' - 配置路径:', socketIoPath);
|
||||
console.log(' - 规范化配置路径:', normalizedConfigPath);
|
||||
console.log(' - 严格相等:', reqPath === socketIoPath);
|
||||
console.log(' - 规范化后相等:', normalizedReqPath === normalizedConfigPath);
|
||||
console.log(' - 请求路径.endsWith(配置路径):', reqPath.endsWith(socketIoPath));
|
||||
console.log(' - 规范化请求.endsWith(规范化配置):', normalizedReqPath.endsWith(normalizedConfigPath));
|
||||
|
||||
// 使用规范化路径进行比较
|
||||
if (normalizedReqPath !== normalizedConfigPath) {
|
||||
console.warn(`[Socket.IO] ⚠️ 警告: 请求路径与配置路径不匹配!`);
|
||||
console.warn(`[Socket.IO] 原始请求: ${reqPath}`);
|
||||
console.warn(`[Socket.IO] 规范化请求: ${normalizedReqPath}`);
|
||||
console.warn(`[Socket.IO] 配置路径: ${socketIoPath}`);
|
||||
console.warn(`[Socket.IO] 规范化配置: ${normalizedConfigPath}`);
|
||||
console.warn(`[Socket.IO] 差异: ${reqPath.replace(socketIoPath, '【配置路径】')}`);
|
||||
}
|
||||
|
||||
// 创建游戏房间(玩家创建)
|
||||
socket.on('create-room', (data: { difficulty: string; roomId?: string; invitePlayMode?: boolean }) => {
|
||||
const roomId = data.roomId || generateRoomId();
|
||||
const room: GameRoom = {
|
||||
roomId,
|
||||
playerId: socket.id,
|
||||
board: [],
|
||||
difficulty: data.difficulty,
|
||||
spectators: new Set(),
|
||||
highlightedCells: new Set(),
|
||||
invitePlayMode: data.invitePlayMode || false
|
||||
};
|
||||
|
||||
gameRooms.set(roomId, room);
|
||||
socket.join(roomId);
|
||||
|
||||
console.log(`[Socket.IO] 房间创建: ${roomId}, 玩家: ${socket.id}, 邀请同玩模式: ${room.invitePlayMode}`);
|
||||
socket.emit('room-created', { roomId });
|
||||
});
|
||||
|
||||
// 旁观者加入房间
|
||||
socket.on('join-spectate', (data: { roomId: string }) => {
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room) {
|
||||
socket.emit('error', { message: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
room.spectators.add(socket.id);
|
||||
socket.join(data.roomId);
|
||||
|
||||
console.log(`旁观者加入: ${socket.id} -> 房间: ${data.roomId}`);
|
||||
|
||||
// 发送当前游戏状态给旁观者
|
||||
socket.emit('game-state', {
|
||||
board: room.board,
|
||||
difficulty: room.difficulty
|
||||
});
|
||||
});
|
||||
|
||||
// 获取房间信息
|
||||
socket.on('get-room-info', (data: { roomId: string }) => {
|
||||
console.log('[服务器日志] === 获取房间信息开始 ===');
|
||||
console.log('[服务器日志] 收到 get-room-info 事件:');
|
||||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||||
console.log('[服务器日志] - 请求者ID:', socket.id);
|
||||
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room) {
|
||||
console.log('[服务器日志] ❌ 房间不存在');
|
||||
socket.emit('error', { message: '房间不存在' });
|
||||
console.log('[服务器日志] === 获取房间信息结束 ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建房间信息
|
||||
const roomInfo = {
|
||||
roomId: room.roomId,
|
||||
playerCount: 1, // 玩家数量固定为1
|
||||
spectatorCount: room.spectators.size,
|
||||
difficulty: room.difficulty,
|
||||
invitePlayMode: room.invitePlayMode,
|
||||
gameState: 'playing' // 默认游戏状态为进行中
|
||||
};
|
||||
|
||||
console.log('[服务器日志] ✅ 发送房间信息:');
|
||||
console.log('[服务器日志] - 同玩模式:', roomInfo.invitePlayMode ? '开启' : '关闭');
|
||||
console.log('[服务器日志] - 旁观者数量:', roomInfo.spectatorCount);
|
||||
|
||||
socket.emit('room-info', roomInfo);
|
||||
console.log('[服务器日志] === 获取房间信息完成 ===');
|
||||
});
|
||||
|
||||
// 玩家更新游戏状态
|
||||
socket.on('update-game', (data: { roomId: string; board: Cell[][] }) => {
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room || room.playerId !== socket.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
room.board = data.board;
|
||||
|
||||
// 广播给该房间的所有旁观者
|
||||
socket.to(data.roomId).emit('game-state', {
|
||||
board: room.board,
|
||||
difficulty: room.difficulty,
|
||||
highlightedCells: Array.from(room.highlightedCells)
|
||||
});
|
||||
|
||||
// 清除闪烁的格子
|
||||
if (room.highlightedCells.size > 0) {
|
||||
setTimeout(() => {
|
||||
room.highlightedCells.clear();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 旁观者清除所有高亮
|
||||
socket.on('clear-all-highlights', (data: { roomId: string }) => {
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room || !room.spectators.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除所有高亮格子
|
||||
room.highlightedCells.clear();
|
||||
|
||||
// 通知玩家清除所有高亮
|
||||
io.to(room.playerId).emit('clear-all-highlights');
|
||||
|
||||
// 通知所有旁观者清除所有高亮
|
||||
io.to(data.roomId).emit('clear-all-highlights');
|
||||
|
||||
console.log(`旁观者清除所有高亮: 房间 ${data.roomId}`);
|
||||
});
|
||||
|
||||
// 旁观者点击格子
|
||||
socket.on('spectator-click', (data: { roomId: string; row: number; col: number }) => {
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room || !room.spectators.has(socket.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellKey = `${data.row},${data.col}`;
|
||||
room.highlightedCells.add(cellKey);
|
||||
|
||||
console.log(`旁观者点击: 房间 ${data.roomId}, 格子 (${data.row}, ${data.col})`);
|
||||
|
||||
// 通知玩家该格子被旁观者点击
|
||||
io.to(room.playerId).emit('spectator-suggest', {
|
||||
row: data.row,
|
||||
col: data.col
|
||||
});
|
||||
|
||||
// 3秒后移除闪烁
|
||||
setTimeout(() => {
|
||||
room.highlightedCells.delete(cellKey);
|
||||
io.to(data.roomId).emit('clear-highlight', { row: data.row, col: data.col });
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// 旁观者操作格子(同玩模式)
|
||||
socket.on('spectator-action', (data: { roomId: string; action: 'reveal' | 'flag' | 'chord'; row: number; col: number }) => {
|
||||
console.log('[服务器日志] === 旁观者操作开始 ===');
|
||||
console.log('[服务器日志] 收到 spectator-action 事件:');
|
||||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||||
console.log('[服务器日志] - 动作类型:', data.action);
|
||||
console.log('[服务器日志] - 格子坐标:', `(${data.row}, ${data.col})`);
|
||||
console.log('[服务器日志] - 旁观者ID:', socket.id);
|
||||
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room) {
|
||||
console.log('[服务器日志] ❌ 房间不存在,操作失败');
|
||||
console.log('[服务器日志] 当前存在的房间:', Array.from(gameRooms.keys()));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[服务器日志] 房间信息:');
|
||||
console.log('[服务器日志] - 玩家ID:', room.playerId);
|
||||
console.log('[服务器日志] - 旁观者数量:', room.spectators.size);
|
||||
console.log('[服务器日志] - 同玩模式:', room.invitePlayMode);
|
||||
console.log('[服务器日志] - 当前旁观者:', Array.from(room.spectators));
|
||||
|
||||
if (!room.spectators.has(socket.id)) {
|
||||
console.log('[服务器日志] ❌ 旁观者不在房间中,操作失败');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room.invitePlayMode) {
|
||||
console.log('[服务器日志] ❌ 房间未开启同玩模式,操作失败');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[服务器日志] ✅ 旁观者操作有效: 房间 ${data.roomId}, 动作: ${data.action}, 格子 (${data.row}, ${data.col})`);
|
||||
|
||||
// 转发操作给玩家
|
||||
console.log('[服务器日志] 转发操作给玩家:', room.playerId);
|
||||
io.to(room.playerId).emit('spectator-action', {
|
||||
action: data.action,
|
||||
row: data.row,
|
||||
col: data.col
|
||||
});
|
||||
console.log('[服务器日志] === 旁观者操作完成 ===');
|
||||
});
|
||||
|
||||
// 玩家更新难度
|
||||
socket.on('update-difficulty', (data: { roomId: string; difficulty: string; config?: any }) => {
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room || room.playerId !== socket.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`玩家更新难度: 房间 ${data.roomId}, 从 ${room.difficulty} 变更为 ${data.difficulty}`);
|
||||
console.log('配置信息:', data.config);
|
||||
room.difficulty = data.difficulty;
|
||||
|
||||
// 广播难度更新给房间内所有旁观者
|
||||
io.to(data.roomId).emit('difficulty-updated', {
|
||||
difficulty: data.difficulty,
|
||||
config: data.config // 传递配置信息给旁观者
|
||||
});
|
||||
});
|
||||
|
||||
// 玩家切换同玩模式
|
||||
socket.on('toggle-invite-play-mode', (data: { roomId: string; invitePlayMode: boolean }) => {
|
||||
console.log('[服务器日志] === 切换同玩模式开始 ===');
|
||||
console.log('[服务器日志] 收到 toggle-invite-play-mode 事件:');
|
||||
console.log('[服务器日志] - 房间ID:', data.roomId);
|
||||
console.log('[服务器日志] - 同玩模式:', data.invitePlayMode ? '开启' : '关闭');
|
||||
console.log('[服务器日志] - 玩家ID:', socket.id);
|
||||
|
||||
const room = gameRooms.get(data.roomId);
|
||||
|
||||
if (!room || room.playerId !== socket.id) {
|
||||
console.log('[服务器日志] ❌ 房间不存在或玩家不匹配,操作失败');
|
||||
console.log('[服务器日志] === 切换同玩模式结束 ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新房间的同玩模式状态
|
||||
room.invitePlayMode = data.invitePlayMode;
|
||||
|
||||
console.log(`[服务器日志] ✅ 同玩模式已更新: ${data.invitePlayMode ? '开启' : '关闭'}`);
|
||||
|
||||
// 广播同玩模式更新给房间内所有旁观者
|
||||
console.log('[服务器日志] 广播同玩模式更新给旁观者');
|
||||
io.to(data.roomId).emit('invite-play-mode-updated', {
|
||||
invitePlayMode: data.invitePlayMode
|
||||
});
|
||||
|
||||
console.log('[服务器日志] === 切换同玩模式完成 ===');
|
||||
});
|
||||
|
||||
// 断开连接
|
||||
socket.on('disconnect', () => {
|
||||
console.log('用户断开连接:', socket.id);
|
||||
|
||||
// 清理房间数据
|
||||
gameRooms.forEach((room, roomId) => {
|
||||
if (room.playerId === socket.id) {
|
||||
// 玩家离开,关闭房间
|
||||
io.to(roomId).emit('room-closed');
|
||||
gameRooms.delete(roomId);
|
||||
console.log(`房间关闭: ${roomId}`);
|
||||
} else if (room.spectators.has(socket.id)) {
|
||||
// 旁观者离开
|
||||
room.spectators.delete(socket.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
// 生成唯一房间ID
|
||||
function generateRoomId(): string {
|
||||
return Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||
}
|
||||
Reference in New Issue
Block a user