diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index 2ecea57..8bf089a 100644 Binary files a/backend/prisma/prisma/dev.db and b/backend/prisma/prisma/dev.db differ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c9c9f6b..e77b82c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -7,33 +7,15 @@ datasource db { url = "file:./prisma/dev.db" } -enum Role { - STUDENT - LIBRARIAN - ADMIN -} - -enum HoldStatus { - WAITING - READY - CANCELLED -} - -enum CopyStatus { - AVAILABLE - BORROWED - LOST - DAMAGED -} - +<<<<<<< HEAD model User { id Int @id @default(autoincrement()) name String email String @unique passwordHash String studentId String? @unique - employeeId String? @unique // 馆员工号(你的功能需要) - role Role @default(STUDENT) + employeeId String? @unique + role String @default("STUDENT") isBlocked Boolean @default(false) blockReason String? blockedAt DateTime? @@ -46,9 +28,10 @@ model User { auditLogs AuditLog[] dueReminderLogs DueReminderLog[] announcementPublishers AnnouncementPublisher[] - sentMessages Message[] @relation("MessageSender") // 站内信(你的功能需要) - receivedMessages Message[] @relation("MessageReceiver") // 站内信(你的功能需要) + sentMessages Message[] @relation("MessageSender") + receivedMessages Message[] @relation("MessageReceiver") reminderLogs ReminderLog[] + reservations Reservation[] } model Book { @@ -69,38 +52,100 @@ model Book { } model Copy { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) bookId Int - book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) - barcode String @unique + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + barcode String @unique floor Int? libraryArea String? shelfNo String? shelfLevel Int? - status CopyStatus @default(AVAILABLE) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status String @default("AVAILABLE") // AVAILABLE, BORROWED, LOST, DAMAGED, RESERVED + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt loans Loan[] + reservations Reservation[] +======= +enum Role { + STUDENT + LIBRARIAN + ADMIN +} + +enum HoldStatus { + WAITING + READY + CANCELLED +} + +model User { + id Int @id @default(autoincrement()) + name String + email String @unique + passwordHash String + studentId String? @unique + role Role @default(STUDENT) + createdAt DateTime @default(now()) + loans Loan[] + ratings Rating[] + holds Hold[] + wishlists Wishlist[] + auditLogs AuditLog[] +} + +model Book { + id Int @id @default(autoincrement()) + title String + author String + isbn String @unique + genre String + description String? + language String @default("English") + shelfLocation String? + available Boolean @default(true) // 保留兼容 + totalCopies Int @default(1) // 新增 + availableCopies Int @default(1) // 新增 + createdAt DateTime @default(now()) + loans Loan[] + ratings Rating[] + holds Hold[] + wishlists Wishlist[] +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Loan { id Int @id @default(autoincrement()) +<<<<<<< HEAD copyId Int userId Int - barcode String @unique // 借阅条形码 BC-xxxxxx-xxx + barcode String @unique checkoutDate DateTime @default(now()) dueDate DateTime returnDate DateTime? fineAmount Float @default(0) finePaid Boolean @default(false) fineForgiven Boolean @default(false) - renewCount Int @default(0) // 续借次数(你的功能需要) + renewCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt copy Copy @relation(fields: [copyId], references: [id]) user User @relation(fields: [userId], references: [id]) reminderLogs ReminderLog[] dueReminderLogs DueReminderLog[] +======= + bookId Int + userId Int + checkoutDate DateTime @default(now()) + dueDate DateTime + returnDate DateTime? + fineAmount Float? @default(0) + finePaid Boolean? @default(false) + fineForgiven Boolean? @default(false) + renewCount Int @default(0) + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id]) + user User @relation(fields: [userId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Rating { @@ -108,23 +153,38 @@ model Rating { bookId Int userId Int stars Int @default(1) - review String? // 评论内容(你的功能需要) +<<<<<<< HEAD + review String? createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // 评论更新时间(你的功能需要) + updatedAt DateTime @updatedAt book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) +======= + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) } model Hold { +<<<<<<< HEAD + id Int @id @default(autoincrement()) + bookId Int + userId Int + status String @default("WAITING") // WAITING, READY, CANCELLED + createdAt DateTime @default(now()) + book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) +======= id Int @id @default(autoincrement()) bookId Int userId Int status HoldStatus @default(WAITING) createdAt DateTime @default(now()) - book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) + book Book @relation(fields: [bookId], references: [id]) user User @relation(fields: [userId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model Wishlist { @@ -132,18 +192,28 @@ model Wishlist { bookId Int userId Int createdAt DateTime @default(now()) +<<<<<<< HEAD book Book @relation(fields: [bookId], references: [id], onDelete: Cascade) +======= + book Book @relation(fields: [bookId], references: [id]) +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b user User @relation(fields: [userId], references: [id]) @@unique([bookId, userId]) } model Config { +<<<<<<< HEAD id Int @id @default(autoincrement()) key String @unique value String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +======= + id Int @id @default(autoincrement()) + key String @unique + value String +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } model AuditLog { @@ -156,6 +226,7 @@ model AuditLog { createdAt DateTime @default(now()) user User? @relation(fields: [userId], references: [id]) } +<<<<<<< HEAD model Announcement { id Int @id @default(autoincrement()) @@ -196,24 +267,23 @@ model BackupLog { filename String filepath String sizeBytes BigInt - status String @default("completed") // completed, failed, restored - type String @default("manual") // manual, scheduled + status String @default("completed") + type String @default("manual") note String? createdAt DateTime @default(now()) restoredAt DateTime? - createdBy Int? // 管理员 userId + createdBy Int? } -// 图书到期提醒日志模型 model ReminderLog { id Int @id @default(autoincrement()) userId Int loanId Int - bookTitle String // 图书名称 - dueDate DateTime // 图书到期日期 - sendStatus String @default("success") // success, failed + bookTitle String + dueDate DateTime + sendStatus String @default("success") sendTime DateTime @default(now()) - errorMessage String? // 如果发送失败,记录错误信息 + errorMessage String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade) @@ -232,4 +302,33 @@ model DueReminderLog { loan Loan @relation(fields: [loanId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id]) book Book @relation(fields: [bookId], references: [id]) +} + +// 新增:预约模型 +model Reservation { + id Int @id @default(autoincrement()) + copyId Int + userId Int + status String @default("PENDING") // PENDING, CONFIRMED, EXPIRED, CANCELLED + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + copy Copy @relation(fields: [copyId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([copyId]) + @@index([userId]) + @@index([status, expiresAt]) +======= +model Librarian { + id Int @id @default(autoincrement()) + employeeId String @unique @map("employee_id") + name String + password String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("librarians") +>>>>>>> af9ecfbeebfa89b807d4957f9b88257908c13b6b } \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 09a4250..f27387a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -27,13 +27,14 @@ const blocklistRouter = require("./routes/blocklist"); const remindersRouter = require('./routes/reminders'); // 图书到期提醒路由 const backupService = require("./services/backup"); const { runDueReminderJob, getNextDueReminderTime } = require('./services/dueReminder'); +const { expireReservations } = require('./services/expireReservations'); // 新增:预约过期清理 const app = express(); const port = Number(process.env.PORT) || 3001; // 必须的中间件 app.use(cors()); -app.use(express.json({ limit: '10mb' })); +app.use(express.json()); app.use(express.urlencoded({ extended: false })); // 健康检查 @@ -177,6 +178,19 @@ async function startServer() { startReminderScheduler(); // 启动图书到期提醒定时任务 startDueReminderScheduler(); + // ========== 新增:预约过期清理定时任务(每5分钟执行一次) ========== + cron.schedule('*/5 * * * *', async () => { + try { + const result = await expireReservations(); + if (result.processed > 0) { + console.log(`[${new Date().toISOString()}] 📅 预约过期清理: 处理 ${result.processed} 个,释放 ${result.released} 个副本`); + } + } catch (error) { + console.error(`[${new Date().toISOString()}] ❌ 预约过期清理失败:`, error.message); + } + }); + console.log('⏰ 预约过期清理定时任务已启动(每5分钟执行一次)'); + app.listen(port, () => { console.log(` ╔═══════════════════════════════════════════════════════╗ @@ -190,6 +204,7 @@ async function startServer() { ║ 💾 Backup endpoints: /api/backups/* ║ ║ ⏰ Auto backup every ${BACKUP_INTERVAL_MS / 3600000} hours ║ ║ 📧 Reminder endpoints: /api/librarian/reminders/* ║ +║ 📅 Reservation expiry cleanup: every 5 minutes ║ ╚═══════════════════════════════════════════════════════╝ `); }); @@ -224,4 +239,4 @@ process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); -startServer(); +startServer(); \ No newline at end of file diff --git a/backend/src/routes/reader-borrow.js b/backend/src/routes/reader-borrow.js index a8ca704..c509e81 100644 --- a/backend/src/routes/reader-borrow.js +++ b/backend/src/routes/reader-borrow.js @@ -250,6 +250,8 @@ async function handlePayFine(req, res) { } } +// ==================== 原有功能(保留) ==================== + // 获取我的借阅列表(包括已归还和未归还) router.get('/my-borrows', requireAuth, async (req, res) => { try { @@ -297,15 +299,17 @@ router.get('/available-copies/:bookId', requireAuth, async (req, res) => { } }); -// 借阅图书(选择具体副本) +// ==================== 修改:借阅改为预约 ==================== +// 预约图书(原来的直接借阅改为预约,2小时内到馆确认) router.post('/borrow/:copyId', requireAuth, async (req, res) => { if (req.user.isBlocked) { - return res.status(403).json({ message: `您的账号已被封禁,无法借阅书籍。封禁原因:${req.user.blockReason || '违反图书馆相关规定'}` }); + return res.status(403).json({ message: `您的账号已被封禁,无法预约书籍。封禁原因:${req.user.blockReason || '违反图书馆相关规定'}` }); } try { const copyId = parseInt(req.params.copyId); + // 1. 检查副本是否存在且可预约 const copy = await prisma.copy.findUnique({ where: { id: copyId }, include: { book: true } @@ -316,16 +320,31 @@ router.post('/borrow/:copyId', requireAuth, async (req, res) => { } if (copy.status !== 'AVAILABLE') { - return res.status(400).json({ message: '该副本不可借' }); + return res.status(400).json({ message: '该副本不可预约(已被借出或损坏)' }); + } + + // 2. 检查用户是否有未完成的预约(防止重复预约同一本书) + const existingReservation = await prisma.reservation.findFirst({ + where: { + userId: req.user.id, + copyId: copyId, + status: 'PENDING' + } + }); + + if (existingReservation) { + return res.status(400).json({ message: '您已预约过这本书,请尽快到图书馆借出' }); } - const currentCount = await prisma.loan.count({ + // 3. 检查用户当前借阅数量限制 + const currentBorrowCount = await prisma.loan.count({ where: { userId: req.user.id, returnDate: null } }); - if (currentCount >= MAX_BORROW_LIMIT) { - return res.status(400).json({ message: `最多同时借阅${MAX_BORROW_LIMIT}本书` }); + if (currentBorrowCount >= MAX_BORROW_LIMIT) { + return res.status(400).json({ message: `您当前借阅数量已达上限(${MAX_BORROW_LIMIT}本),请先归还部分图书` }); } + // 4. 检查是否已借阅过这本书(未归还) const existingLoan = await prisma.loan.findFirst({ where: { userId: req.user.id, @@ -337,58 +356,152 @@ router.post('/borrow/:copyId', requireAuth, async (req, res) => { return res.status(400).json({ message: '您已借阅过这本书,请先归还' }); } - const dueDate = new Date(); - dueDate.setDate(dueDate.getDate() + 14); + // 5. 创建预约记录,并临时锁定副本状态 + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 2); // 2小时后过期 - const barcode = await generateUniqueBarcode(); - - const loan = await prisma.loan.create({ - data: { - copyId: copyId, + // 使用事务:创建预约 + 更新副本状态为 RESERVED + const reservation = await prisma.$transaction(async (tx) => { + // 再次确认副本仍然可用(防止并发) + const currentCopy = await tx.copy.findUnique({ + where: { id: copyId } + }); + + if (currentCopy.status !== 'AVAILABLE') { + throw new Error('该副本已被他人预约或借出'); + } + + // 更新副本状态为预约锁定 + await tx.copy.update({ + where: { id: copyId }, + data: { status: 'RESERVED' } + }); + + // 创建预约记录 + return tx.reservation.create({ + data: { + copyId: copyId, + userId: req.user.id, + expiresAt: expiresAt, + status: 'PENDING' + } + }); + }); + + writeAuditLog({ + userId: req.user.id, + action: 'RESERVE_BOOK', + entity: 'Reservation', + entityId: reservation.id, + detail: `读者 ${req.user.email} 预约了《${copy.book.title}》(副本 ${copyId}),有效期至 ${expiresAt.toISOString()}`, + }); + + res.status(201).json({ + success: true, + message: `预约成功!请在2小时内到图书馆借出《${copy.book.title}》,否则预约将自动失效。`, + reservation: { + id: reservation.id, + bookTitle: copy.book.title, + copyBarcode: copy.barcode, + expiresAt: expiresAt, + expiresInMinutes: 120 + } + }); + } catch (error) { + console.error('预约失败:', error); + res.status(500).json({ message: error.message || '预约失败' }); + } +}); + +// ==================== 新增:预约相关接口 ==================== + +// 获取我的预约列表 +router.get('/my-reservations', requireAuth, async (req, res) => { + try { + const reservations = await prisma.reservation.findMany({ + where: { userId: req.user.id, - barcode, - dueDate: dueDate, - fineAmount: 0, - finePaid: false, - fineForgiven: false, - renewCount: 0 + status: { in: ['PENDING', 'EXPIRED', 'CANCELLED'] } }, include: { copy: { include: { book: true } } - } + }, + orderBy: { createdAt: 'desc' } }); - await prisma.copy.update({ - where: { id: copyId }, - data: { status: 'BORROWED' } + const now = new Date(); + const formattedReservations = reservations.map(res => ({ + id: res.id, + bookTitle: res.copy.book.title, + bookAuthor: res.copy.book.author, + copyBarcode: res.copy.barcode, + status: res.status, + expiresAt: res.expiresAt, + isExpired: res.status === 'PENDING' && new Date(res.expiresAt) < now, + createdAt: res.createdAt + })); + + res.json({ success: true, reservations: formattedReservations }); + } catch (error) { + console.error('获取预约列表失败:', error); + res.status(500).json({ success: false, message: '获取预约列表失败' }); + } +}); + +// 取消预约 +router.post('/cancel-reservation/:reservationId', requireAuth, async (req, res) => { + try { + const reservationId = parseInt(req.params.reservationId); + + const reservation = await prisma.reservation.findUnique({ + where: { id: reservationId }, + include: { copy: true, user: true } + }); + + if (!reservation) { + return res.status(404).json({ success: false, message: '预约记录不存在' }); + } + + if (reservation.userId !== req.user.id) { + return res.status(403).json({ success: false, message: '无权取消此预约' }); + } + + if (reservation.status !== 'PENDING') { + return res.status(400).json({ success: false, message: `预约状态为 ${reservation.status},无法取消` }); + } + + // 使用事务:更新预约状态 + 释放副本 + await prisma.$transaction(async (tx) => { + await tx.reservation.update({ + where: { id: reservationId }, + data: { status: 'CANCELLED' } + }); + + await tx.copy.update({ + where: { id: reservation.copyId }, + data: { status: 'AVAILABLE' } + }); }); writeAuditLog({ userId: req.user.id, - action: 'BORROW_BOOK', - entity: 'Loan', - entityId: loan.id, - detail: `读者 ${req.user.email} 自助借阅《${loan.copy.book.title}》(副本 ${copyId})`, + action: 'CANCEL_RESERVATION', + entity: 'Reservation', + entityId: reservationId, + detail: `读者 ${req.user.email} 取消了预约`, }); - res.status(201).json({ - message: '借阅成功', - loan: { - id: loan.id, - barcode: loan.barcode, - bookTitle: loan.copy.book.title, - copyBarcode: loan.copy.barcode, - dueDate: loan.dueDate - } - }); + res.json({ success: true, message: '预约已取消,库存已释放' }); } catch (error) { - console.error(error); - res.status(500).json({ message: '借阅失败' }); + console.error('取消预约失败:', error); + res.status(500).json({ success: false, message: '取消预约失败' }); } }); +// ==================== 原有功能(保留) ==================== + // 续借图书 - 使用 copyId router.post('/renew', requireAuth, async (req, res) => { if (req.user.isBlocked) { diff --git a/backend/src/services/expireReservations.js b/backend/src/services/expireReservations.js new file mode 100644 index 0000000..c28a048 --- /dev/null +++ b/backend/src/services/expireReservations.js @@ -0,0 +1,77 @@ +// backend/src/services/expireReservations.js +const prisma = require('../lib/prisma'); + +/** + * 扫描并释放过期的预约 + * 将过期的 PENDING 状态预约标记为 EXPIRED,并释放对应的副本库存 + */ +async function expireReservations() { + const now = new Date(); + + console.log(`[${new Date().toISOString()}] 开始扫描过期预约...`); + + try { + // 查找所有过期的 PENDING 预约 + const expiredReservations = await prisma.reservation.findMany({ + where: { + status: 'PENDING', + expiresAt: { lt: now } + }, + include: { + copy: { + include: { book: true } + }, + user: true + } + }); + + if (expiredReservations.length === 0) { + console.log('没有过期的预约需要处理'); + return { processed: 0, released: 0 }; + } + + console.log(`发现 ${expiredReservations.length} 个过期预约,开始处理...`); + + let releasedCount = 0; + + for (const reservation of expiredReservations) { + try { + // 使用事务:更新预约状态 + 释放副本 + await prisma.$transaction(async (tx) => { + // 更新预约状态为 EXPIRED + await tx.reservation.update({ + where: { id: reservation.id }, + data: { status: 'EXPIRED' } + }); + + // 释放副本(如果副本状态仍然是 RESERVED) + const copy = await tx.copy.findUnique({ + where: { id: reservation.copyId } + }); + + if (copy.status === 'RESERVED') { + await tx.copy.update({ + where: { id: reservation.copyId }, + data: { status: 'AVAILABLE' } + }); + } + }); + + releasedCount++; + console.log(`✅ 已释放预约 #${reservation.id}: 图书 ${reservation.copy.book.title}, 用户 ${reservation.user.email}`); + + } catch (error) { + console.error(`处理预约 #${reservation.id} 失败:`, error.message); + } + } + + console.log(`处理完成:共处理 ${expiredReservations.length} 个过期预约,成功释放 ${releasedCount} 个副本`); + + return { processed: expiredReservations.length, released: releasedCount }; + } catch (error) { + console.error('扫描过期预约失败:', error); + throw error; + } +} + +module.exports = { expireReservations }; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ab15788..2947a36 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { Routes, Route, useNavigate } from 'react-router-dom'; import BookSearch from './pages/BookSearch'; import HomePage from './pages/HomePage'; import MyHistory from './reader/MyHistory'; +import MyReservations from './reader/MyReservations'; import UnifiedLogin from './pages/UnifiedLogin'; import Register from './pages/Register'; import AdminDashboard from './pages/AdminDashboard'; @@ -16,12 +17,10 @@ import SystemConfig from './pages/SystemConfig'; import AdminBackupPage from './pages/AdminBackupPage'; import AdminBlocklist from './pages/AdminBlocklist'; - function App() { const navigate = useNavigate(); const [isLoggedIn, setIsLoggedIn] = useState(false); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState('search'); useEffect(() => { const token = localStorage.getItem('token') || localStorage.getItem('librarianToken'); @@ -41,16 +40,15 @@ function App() { localStorage.removeItem('librarianToken'); localStorage.removeItem('librarianInfo'); setIsLoggedIn(false); - setActiveTab('search'); navigate('/login'); }; - if (loading) return
读者端
-搜索并浏览图书馆的图书
+作者: {book.author}
+ISBN: {book.isbn}
+库存: {book.availableCopies || 0} / {book.totalCopies || 1}
++ 评分: + {book.averageRating !== null ? ( + {'★'.repeat(Math.round(book.averageRating))}{'☆'.repeat(5 - Math.round(book.averageRating))} + ) : ( + 暂无评分 + )} + {book.averageRating !== null && ( + ({book.averageRating}/5, {book.totalRatings}条评价) + )} +
+{getGreeting()}
-{user?.name || '读者'}
+ {showCopies === book.id && (!availableCopies[book.id] || availableCopies[book.id].length === 0) && ( +作者: {selectedBook.author}
+ISBN: {selectedBook.isbn}
+分类: {selectedBook.genre}
+语言: {selectedBook.language || 'English'}
+库存: {selectedBook.availableCopies || 0} / {selectedBook.totalCopies || 1}
+ {bookRatings && bookRatings.totalRatings > 0 && ( +
+ 评分:
{selectedBook.description}
+{rating.review}
+ )} ++ 还有 {bookRatings.totalRatings - 5} 条评价... +
+ )} +搜索并浏览图书馆的图书
+搜索并浏览图书馆的图书
+欢迎回来,在这里您可以搜索和借阅图书。
查看和管理您的图书预约
+ +搜索并浏览图书馆的图书
+搜索并浏览图书馆的图书
+查看和管理您的借阅记录
+查看和管理您的借阅记录
+暂无预约记录
+作者: {reservation.bookAuthor}
++ 预约时间: {new Date(reservation.createdAt).toLocaleString()} +
++ 状态: {reservation.status === 'PENDING' ? '待处理' : + reservation.status === 'EXPIRED' ? '已过期' : '已取消'} +
+