Skip to content

Commit 085fb90

Browse files
author
CodeJudge
committed
feat: 题目导入导出 + API文档
1 parent c2ae281 commit 085fb90

12 files changed

Lines changed: 371 additions & 41 deletions

File tree

backend/src/app.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const express = require('express');
22
const cors = require('cors');
33
const path = require('path');
44
const { initDb } = require('./utils/initDb');
5+
const { rateLimit } = require('./middleware/rateLimit');
56
const authRoutes = require('./routes/auth');
67
const problemRoutes = require('./routes/problems');
78
const submissionRoutes = require('./routes/submissions');
@@ -12,6 +13,21 @@ const PORT = process.env.PORT || 3001;
1213
app.use(cors());
1314
app.use(express.json({ limit: '10mb' }));
1415

16+
// Security headers
17+
app.use((req, res, next) => {
18+
res.setHeader('X-Content-Type-Options', 'nosniff');
19+
res.setHeader('X-Frame-Options', 'DENY');
20+
res.setHeader('X-XSS-Protection', '1; mode=block');
21+
res.setHeader('Referrer-Policy', 'same-origin');
22+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
23+
next();
24+
});
25+
26+
// Rate limiting per route group
27+
app.use('/api/auth', rateLimit({ windowMs: 60000, max: 30 }));
28+
app.use('/api/problems', rateLimit({ windowMs: 60000, max: 60 }));
29+
app.use('/api/submissions', rateLimit({ windowMs: 60000, max: 20 }));
30+
1531
// Request logger
1632
app.use((req, res, next) => {
1733
const start = Date.now();

backend/src/controllers/problemController.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,37 @@ function getProblemStats(req, res) {
141141
res.json(stats || { total: 0, programming: 0, choice: 0, fill_blank: 0 });
142142
}
143143

144-
module.exports = { listProblems, getProblem, createProblem, updateProblem, deleteProblem, getProblemStats, getAdminStats, getTagsCloud };
144+
function exportProblems(req, res) {
145+
const problems = queryAll('SELECT * FROM problems ORDER BY id');
146+
const data = problems.map(p => ({
147+
title: p.title, description: p.description, type: p.type,
148+
difficulty: p.difficulty, tags: p.tags, solution: p.solution,
149+
test_cases: JSON.parse(p.test_cases || '[]'),
150+
options: JSON.parse(p.options || '[]'),
151+
blanks_answer: JSON.parse(p.blanks_answer || '[]'),
152+
}));
153+
res.setHeader('Content-Type', 'application/json');
154+
res.setHeader('Content-Disposition', `attachment; filename=codejudge-problems-${new Date().toISOString().slice(0,10)}.json`);
155+
res.json({ count: data.length, problems: data });
156+
}
157+
158+
function importProblems(req, res) {
159+
const { problems } = req.body;
160+
if (!Array.isArray(problems) || problems.length === 0) {
161+
return res.status(400).json({ error: '请提供有效的题目数据' });
162+
}
163+
let imported = 0;
164+
for (const p of problems) {
165+
if (!p.title || !p.type) continue;
166+
run(
167+
'INSERT INTO problems (title, description, type, difficulty, tags, solution, test_cases, options, blanks_answer) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
168+
[p.title, p.description || '', p.type, p.difficulty || 'easy', p.tags || '',
169+
p.solution || '', JSON.stringify(p.test_cases || []), JSON.stringify(p.options || []),
170+
JSON.stringify(p.blanks_answer || [])]
171+
);
172+
imported++;
173+
}
174+
res.json({ message: `成功导入 ${imported} 道题目`, count: imported });
175+
}
176+
177+
module.exports = { listProblems, getProblem, createProblem, updateProblem, deleteProblem, getProblemStats, getAdminStats, getTagsCloud, exportProblems, importProblems };
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Simple in-memory rate limiter
2+
const requestCounts = new Map();
3+
4+
function rateLimit({ windowMs = 60000, max = 100, message = '请求过于频繁,请稍后再试' } = {}) {
5+
return (req, res, next) => {
6+
const ip = req.ip || req.connection.remoteAddress || 'unknown';
7+
const key = `${ip}:${req.path}`;
8+
const now = Date.now();
9+
10+
if (!requestCounts.has(key)) {
11+
requestCounts.set(key, { count: 1, start: now });
12+
return next();
13+
}
14+
15+
const data = requestCounts.get(key);
16+
if (now - data.start > windowMs) {
17+
// Reset window
18+
requestCounts.set(key, { count: 1, start: now });
19+
return next();
20+
}
21+
22+
data.count++;
23+
if (data.count > max) {
24+
return res.status(429).json({ error: message });
25+
}
26+
27+
next();
28+
};
29+
}
30+
31+
// Clean up stale entries every 5 minutes
32+
setInterval(() => {
33+
const now = Date.now();
34+
for (const [key, data] of requestCounts) {
35+
if (now - data.start > 120000) { // 2 min stale
36+
requestCounts.delete(key);
37+
}
38+
}
39+
}, 300000);
40+
41+
module.exports = { rateLimit };

backend/src/routes/auth.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,26 @@ router.get('/solved-calendar', authenticate, (req, res) => {
1616
res.json({ dates: rows.map(r => r.date) });
1717
});
1818

19+
router.get('/streak', authenticate, (req, res) => {
20+
const rows = queryAll(
21+
"SELECT DISTINCT DATE(created_at) as date FROM submissions WHERE user_id = ? ORDER BY date DESC LIMIT 60",
22+
[req.user.id]
23+
);
24+
const dates = rows.map(r => r.date);
25+
let streak = 0;
26+
const today = new Date().toISOString().slice(0, 10);
27+
const todayChecked = dates.includes(today);
28+
const checkDate = new Date();
29+
if (!todayChecked) checkDate.setDate(checkDate.getDate() - 1);
30+
for (const d of dates) {
31+
const expected = checkDate.toISOString().slice(0, 10);
32+
if (d === expected) { streak++; checkDate.setDate(checkDate.getDate() - 1); }
33+
else break;
34+
}
35+
if (todayChecked && streak === 0) streak = 1;
36+
res.json({ streak, todayChecked });
37+
});
38+
1939
router.get('/leaderboard', async (req, res) => {
2040
try {
2141
const rows = queryAll(`

backend/src/routes/problems.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const router = require('express').Router();
22
const { queryOne } = require('../config/db');
3-
const { listProblems, getProblem, createProblem, updateProblem, deleteProblem, getProblemStats, getAdminStats, getTagsCloud } = require('../controllers/problemController');
3+
const { listProblems, getProblem, createProblem, updateProblem, deleteProblem, getProblemStats, getAdminStats, getTagsCloud, exportProblems, importProblems } = require('../controllers/problemController');
44
const { getTemplate } = require('../services/judgeService');
55
const { optionalAuth, adminOnly } = require('../middleware/auth');
66

@@ -40,9 +40,45 @@ router.get('/random', (req, res) => {
4040
res.json({ problem });
4141
});
4242
router.get('/admin/stats', adminOnly, getAdminStats);
43+
router.get('/export', adminOnly, exportProblems);
44+
router.post('/import', adminOnly, importProblems);
4345
router.get('/:id', optionalAuth, getProblem);
4446
router.post('/', adminOnly, createProblem);
4547
router.put('/:id', adminOnly, updateProblem);
4648
router.delete('/:id', adminOnly, deleteProblem);
4749

50+
router.get('/docs', (req, res) => {
51+
res.json({
52+
name: 'CodeJudge API',
53+
version: '1.0',
54+
baseUrl: '/api',
55+
endpoints: [
56+
{ method: 'POST', path: '/auth/register', auth: false, desc: '用户注册' },
57+
{ method: 'POST', path: '/auth/login', auth: false, desc: '用户登录' },
58+
{ method: 'GET', path: '/auth/profile', auth: true, desc: '获取用户信息' },
59+
{ method: 'PUT', path: '/auth/password', auth: true, desc: '修改密码' },
60+
{ method: 'GET', path: '/auth/leaderboard', auth: false, desc: '排行榜' },
61+
{ method: 'GET', path: '/auth/streak', auth: true, desc: '打卡连续天数' },
62+
{ method: 'GET', path: '/auth/solved-calendar', auth: true, desc: '解题日历' },
63+
{ method: 'GET', path: '/problems', auth: false, desc: '题目列表' },
64+
{ method: 'GET', path: '/problems/stats', auth: false, desc: '题目统计' },
65+
{ method: 'GET', path: '/problems/tags', auth: false, desc: '标签云' },
66+
{ method: 'GET', path: '/problems/daily', auth: false, desc: '每日一题' },
67+
{ method: 'GET', path: '/problems/random', auth: false, desc: '随机一题' },
68+
{ method: 'GET', path: '/problems/export', auth: 'admin', desc: '导出题目' },
69+
{ method: 'POST', path: '/problems/import', auth: 'admin', desc: '导入题目' },
70+
{ method: 'GET', path: '/problems/admin/stats', auth: 'admin', desc: '管理统计' },
71+
{ method: 'GET', path: '/problems/templates/:lang', auth: false, desc: '代码模板' },
72+
{ method: 'GET', path: '/problems/:id', auth: false, desc: '题目详情' },
73+
{ method: 'POST', path: '/problems', auth: 'admin', desc: '创建题目' },
74+
{ method: 'PUT', path: '/problems/:id', auth: 'admin', desc: '更新题目' },
75+
{ method: 'DELETE', path: '/problems/:id', auth: 'admin', desc: '删除题目' },
76+
{ method: 'POST', path: '/submissions', auth: true, desc: '提交答案' },
77+
{ method: 'GET', path: '/submissions', auth: true, desc: '提交记录' },
78+
{ method: 'GET', path: '/submissions/:id', auth: true, desc: '提交详情' },
79+
{ method: 'GET', path: '/health', auth: false, desc: '健康检查' },
80+
],
81+
});
82+
});
83+
4884
module.exports = router;

frontend/index.html

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
<html lang="zh-CN">
33
<head>
44
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
6+
<meta name="description" content="CodeJudge - 在线评测系统,支持编程题、选择题、填空题,在线编写代码实时判题" />
7+
<meta name="keywords" content="在线评测, OJ, 编程, 刷题, 算法, 数据结构" />
8+
<meta property="og:title" content="CodeJudge - 在线评测系统" />
9+
<meta property="og:description" content="编程、选择、填空三种题型,在线编写代码实时判题" />
10+
<meta property="og:type" content="website" />
511
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6-
<link rel="alternate icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>" />
7-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
812
<title>CodeJudge - 在线评测系统</title>
913
<link rel="preconnect" href="https://fonts.googleapis.com" />
1014
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

frontend/src/App.tsx

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,45 @@
11
import { Routes, Route } from 'react-router-dom';
2+
import React, { Suspense, lazy } from 'react';
23
import Navbar from './components/Navbar';
3-
import Home from './pages/Home';
4-
import Login from './pages/Login';
5-
import Register from './pages/Register';
6-
import Problems from './pages/Problems';
7-
import ProblemDetail from './pages/ProblemDetail';
8-
import Submissions from './pages/Submissions';
9-
import Admin from './pages/Admin';
10-
import AdminProblemForm from './pages/AdminProblemForm';
11-
import Leaderboard from './pages/Leaderboard';
12-
import NotFound from './pages/NotFound';
13-
import Profile from './pages/Profile';
144
import Footer from './components/Footer';
155

6+
const Home = lazy(() => import('./pages/Home'));
7+
const Login = lazy(() => import('./pages/Login'));
8+
const Register = lazy(() => import('./pages/Register'));
9+
const Problems = lazy(() => import('./pages/Problems'));
10+
const ProblemDetail = lazy(() => import('./pages/ProblemDetail'));
11+
const Submissions = lazy(() => import('./pages/Submissions'));
12+
const Admin = lazy(() => import('./pages/Admin'));
13+
const AdminProblemForm = lazy(() => import('./pages/AdminProblemForm'));
14+
const Leaderboard = lazy(() => import('./pages/Leaderboard'));
15+
const Profile = lazy(() => import('./pages/Profile'));
16+
const NotFound = lazy(() => import('./pages/NotFound'));
17+
1618
export default function App() {
1719
return (
1820
<div className="min-h-screen bg-dark-950">
1921
<Navbar />
2022
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
21-
<Routes>
22-
<Route path="/" element={<Home />} />
23-
<Route path="/login" element={<Login />} />
24-
<Route path="/register" element={<Register />} />
25-
<Route path="/problems" element={<Problems />} />
26-
<Route path="/problems/:id" element={<ProblemDetail />} />
27-
<Route path="/submissions" element={<Submissions />} />
28-
<Route path="/profile" element={<Profile />} />
29-
<Route path="/admin" element={<Admin />} />
30-
<Route path="/admin/problems/new" element={<AdminProblemForm />} />
31-
<Route path="/admin/problems/:id/edit" element={<AdminProblemForm />} />
32-
<Route path="/leaderboard" element={<Leaderboard />} />
33-
<Route path="*" element={<NotFound />} />
34-
</Routes>
23+
<Suspense fallback={
24+
<div className="flex items-center justify-center min-h-[60vh]">
25+
<div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
26+
</div>
27+
}>
28+
<Routes>
29+
<Route path="/" element={<Home />} />
30+
<Route path="/login" element={<Login />} />
31+
<Route path="/register" element={<Register />} />
32+
<Route path="/problems" element={<Problems />} />
33+
<Route path="/problems/:id" element={<ProblemDetail />} />
34+
<Route path="/submissions" element={<Submissions />} />
35+
<Route path="/profile" element={<Profile />} />
36+
<Route path="/admin" element={<Admin />} />
37+
<Route path="/admin/problems/new" element={<AdminProblemForm />} />
38+
<Route path="/admin/problems/:id/edit" element={<AdminProblemForm />} />
39+
<Route path="/leaderboard" element={<Leaderboard />} />
40+
<Route path="*" element={<NotFound />} />
41+
</Routes>
42+
</Suspense>
3543
<Footer />
3644
</main>
3745
</div>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useState, useEffect } from 'react';
2+
import { Flame, Zap, Award, Star, CalendarCheck } from 'lucide-react';
3+
import { useAuth } from '../context/AuthContext';
4+
5+
interface StatsCheck { total: number; accepted: number; fullScore?: boolean; }
6+
7+
const ACHIEVEMENTS = [
8+
{ id: 'first_submit', name: '初次提交', desc: '完成第一次提交', icon: '🚀', check: (s: StatsCheck) => s.total >= 1 },
9+
{ id: 'ten_submits', name: '十次提交', desc: '累计提交10次', icon: '💪', check: (s: StatsCheck) => s.total >= 10 },
10+
{ id: 'first_ac', name: '首次通过', desc: '通过第一道题目', icon: '🎉', check: (s: StatsCheck) => s.accepted >= 1 },
11+
{ id: 'five_ac', name: '五题通关', desc: '通过5道题目', icon: '⭐', check: (s: StatsCheck) => s.accepted >= 5 },
12+
{ id: 'ten_ac', name: '十题达人', desc: '通过10道题目', icon: '🏆', check: (s: StatsCheck) => s.accepted >= 10 },
13+
{ id: 'full_mark', name: '满分选手', desc: '获得一次100分', icon: '💯', check: (s: StatsCheck) => s.fullScore === true },
14+
];
15+
16+
export default function GamificationSection() {
17+
const { user } = useAuth();
18+
const [stats, setStats] = useState<any>(null);
19+
const [streak, setStreak] = useState(0);
20+
const [todayChecked, setTodayChecked] = useState(false);
21+
22+
useEffect(() => {
23+
if (!user) return;
24+
const token = localStorage.getItem('oj_token');
25+
if (!token) return;
26+
27+
fetch('/api/auth/profile', { headers: { Authorization: `Bearer ${token}` } })
28+
.then(r => r.json())
29+
.then(d => setStats(d.stats || { total: 0, accepted: 0 }))
30+
.catch(() => {});
31+
32+
fetch('/api/auth/streak', { headers: { Authorization: `Bearer ${token}` } })
33+
.then(r => r.json())
34+
.then(d => { setStreak(d.streak || 0); setTodayChecked(d.todayChecked || false); })
35+
.catch(() => {});
36+
}, [user]);
37+
38+
if (!user) return null;
39+
40+
const achievements = ACHIEVEMENTS.map(a => ({
41+
...a,
42+
unlocked: a.check({ ...stats, fullScore: stats?.accepted >= 1 }),
43+
}));
44+
45+
return (
46+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
47+
{/* Streak card */}
48+
<div className="card p-6">
49+
<div className="flex items-center gap-3 mb-4">
50+
<Flame className={`w-6 h-6 ${streak > 0 ? 'text-orange-400' : 'text-dark-500'}`} />
51+
<h3 className="text-lg font-semibold text-white">连续打卡</h3>
52+
</div>
53+
<div className="flex items-center gap-4">
54+
<span className={`text-4xl font-bold ${streak > 0 ? 'text-orange-400' : 'text-dark-500'}`}>
55+
{streak}
56+
</span>
57+
<span className="text-dark-400"></span>
58+
</div>
59+
<div className="mt-3 flex items-center gap-2">
60+
{todayChecked ? (
61+
<span className="flex items-center gap-1 text-emerald-400 text-sm">
62+
<CalendarCheck className="w-4 h-4" /> 今日已打卡
63+
</span>
64+
) : (
65+
<span className="text-dark-500 text-sm">今日尚未提交</span>
66+
)}
67+
</div>
68+
</div>
69+
70+
{/* Achievements card */}
71+
<div className="card p-6">
72+
<div className="flex items-center gap-3 mb-4">
73+
<Award className="w-6 h-6 text-amber-400" />
74+
<h3 className="text-lg font-semibold text-white">成就徽章</h3>
75+
</div>
76+
<div className="grid grid-cols-3 gap-3">
77+
{achievements.map(a => (
78+
<div
79+
key={a.id}
80+
className={`text-center p-2 rounded-lg transition-all ${
81+
a.unlocked ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-dark-800/50 border border-dark-700 opacity-40'
82+
}`}
83+
title={a.desc}
84+
>
85+
<span className="text-2xl block mb-1">{a.icon}</span>
86+
<p className={`text-xs ${a.unlocked ? 'text-amber-400' : 'text-dark-500'}`}>{a.name}</p>
87+
</div>
88+
))}
89+
</div>
90+
</div>
91+
</div>
92+
);
93+
}

0 commit comments

Comments
 (0)