
简介面向计算机相关专业毕业设计阶段的读者这份文档围绕动漫类网站的设计与实现展开适合需要完成网站制作类课题、梳理开发流程与论文写作框架的学生参考。全文以需求分析、可行性分析、系统设计、数据库设计、总体设计、系统实现与测试为主线涉及 MyEclipse、Tomcat、B/S 结构、ORACLE 数据库、Dreamweaver、JavaScript 与 JSP 等技术并给出表结构描述、表间联系、功能模块划分及前后台页面实现思路。压缩包共 1 个 doc 文件约 569KB内容精炼便于直接查阅章节结构与关键技术点。已有 2172 人学习下载可作为动漫网站类毕业设计的选题参考、论文提纲模板与开发流程对照材料帮助读者快速理解从需求到测试的完整实现路径并据此搭建自己的项目文档框架。1. 动漫网站的设计与实现答辩前你要交出的其实是一套可验证的闭环每年这个题目都会被大量重复选择原因很直接——它同时压住前端、后端、数据库和部署四块内容工作量可控演示效果又足够直观。但真正让人翻车的地方从来不是“能不能跑起来”而是“跑起来之后能不能解释清楚”列表页那 20 条数据是怎么筛出来的、收藏按钮点下去改了几张表、两个人同时点收藏计数会不会错。动漫网站的设计与实现这条题目本质上要交的是“需求—表结构—接口—页面”四层能一一对上的闭环外加一份能把闭环讲明白的毕业论文。这篇文章写给两类人一个人要在几周内既做完系统又写完论文的在校生以及需要给这类项目做评审、想知道该往哪儿追问的人。2. 需求边界与表结构动漫网站先砍到六个模块再动手绝大多数这个题目的项目死在需求膨胀上而不是技术上。打开任何一个现成的动漫站弹幕、追番日历、评分短评、排行榜、个性化推荐、离线缓存每一样看起来都不难加起来就是一个团队一个季度的量。一个人的毕业论文项目正确的做法是先把功能列表砍到“每个模块都能在论文里单独写一节、并且能在答辩时被追问三轮”的粒度再开始建表。这一章先定边界再定表最后讲数据库怎么选顺序不能反——表结构定错了后面所有接口都要返工。2.1 用户侧与后台侧的六个必做模块我给这个题目定的标准配置是六个模块三个面向普通访客三个面向管理员。这个数量的好处是论文正好能写五到六章正文每一章对应一个模块的“需求分析 设计 实现 测试”结构天然对齐不用硬凑章节。模块用户侧入口后台能力论文里能讲的技术点番剧库列表、筛选、详情页新增/编辑/上下架分页查询、多条件组合筛选、索引剧集播放分集列表、播放器剧集增删、排序资源地址存储、播放器事件、防盗链检索顶部搜索框关键词命中统计LIKE 与全文索引的取舍用户中心注册登录、收藏、历史用户列表、封禁密码加盐哈希、登录态、越权校验评论详情页评论区审核、删除分页加载、XSS 过滤、软删除数据看板无收藏 Top10、日活曲线聚合查询、GROUP BY番剧库是整个站点的地基它的字段设计直接决定后面五个模块好不好写。列表页要支持“按类型 按年份 按状态 关键词”四个条件任意组合这意味着查询接口不能写死成四条 SQL必须做动态拼装。详情页除了基本信息还要带上剧集列表和评论第一页一次性返回还是分两次请求是个要提前定的问题——我一般选一次性返回剧集数据量小、渲染快评论单独拉数据量大、要分页。用户中心里最容易被低估的是“观看历史”。它看起来只是往表里插一条记录实际上要考虑同一部番剧反复观看时是插入还是更新、历史记录保留多少条、未登录用户的历史放哪儿。最省事的做法是只对登录用户记录并且用(user_id, anime_id)做唯一键重复观看时更新last_episode和updated_at这样一张表永远不会膨胀。评论模块要提前想清楚一件事删评论是物理删除还是软删除。答辩时一定有老师会问“误删了怎么办”软删除加一个deleted_at字段就能答代价是所有查询都要带WHERE deleted_at IS NULL。这个代价很小但很多人一开始不做后期改起来要动十几处 SQL。至于要砍掉的功能理由要具体别写“由于时间关系”。弹幕砍掉是因为它需要长连接和每秒数千条的写入一个人的项目里既压不出数据也讲不清一致性个性化推荐砍掉是因为冷启动阶段没有行为数据写出来只能是“按收藏数排序”换个名字付费会员砍掉涉及支付流程和订单状态机跟动漫站点的核心能力关系不大社交关注砍掉用户关系表一旦引入所有查询都要多一层 JOIN论文里会多出一堆自己都讲不顺的关联。砍功能不是能力不足是把有限的时间投在能被追问的地方。2.2 动漫、剧集、用户、收藏四张核心表的字段取舍建表这一步最忌讳“先把能想到的字段全写上”。字段越多插入语句越长前端表单越难对齐论文里解释每个字段的成本也越高。核心表控制在四张周边表两张评论、历史字段数都在十个上下的量级是比较好讲也比较好维护的规模。-- 动漫主表一部番剧一行剧集信息一律不进这张表 CREATE TABLE anime ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL COMMENT 番剧名, alias VARCHAR(200) DEFAULT NULL COMMENT 别名供搜索匹配, cover_url VARCHAR(255) NOT NULL COMMENT 封面相对路径如 /uploads/cover/1.jpg, year SMALLINT NOT NULL COMMENT 放送年份, region VARCHAR(20) NOT NULL COMMENT 地区标签, category VARCHAR(20) NOT NULL COMMENT 类型热血/日常/科幻, status TINYINT NOT NULL DEFAULT 0 COMMENT 0连载中 1已完结, summary TEXT COMMENT 简介纯文本存储, score DECIMAL(3,1) DEFAULT 0.0 COMMENT 评分一位小数, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category_year (category, year), -- 覆盖「类型年份」组合筛选 KEY idx_title (title) -- 支撑前缀匹配的标题搜索 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句里有三个决定值得说清。第一cover_url存相对路径而不是完整 URL一旦换域名或从本地换到服务器历史数据不用批量改这个坑在后面第五章还会专门讲。第二score用DECIMAL(3,1)而不是FLOAT浮点数在排序和展示时会出现8.7000000001这种结果答辩演示时大屏幕上很难看。第三索引idx_category_year的字段顺序是(category, year)而不是反过来因为类型的选择性更高联合索引遵循最左前缀把区分度大的放前面能让组合筛选更快命中。-- 剧集表一部番剧 N 行靠 anime_id 关联 CREATE TABLE episode ( id BIGINT PRIMARY KEY AUTO_INCREMENT, anime_id BIGINT NOT NULL, ep_no SMALLINT NOT NULL COMMENT 第几集从 1 开始, ep_title VARCHAR(120) DEFAULT NULL, play_url VARCHAR(500) NOT NULL COMMENT 播放地址演示用请使用自制或已授权样片, duration INT DEFAULT 0 COMMENT 时长单位秒, UNIQUE KEY uk_anime_ep (anime_id, ep_no), -- 同一部番剧集号不允许重复 KEY idx_anime (anime_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_anime_ep这个唯一键很关键。后台录入剧集时管理员手滑把第 3 集录两遍是常有的事没有唯一约束就只能靠代码判断代码判断就会漏。数据库层面拦住插入时报Duplicate entry直接提示“集号已存在”比任何前端校验都可靠。-- 收藏表把「谁收藏了什么」做成唯一事实 CREATE TABLE favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, anime_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_anime (user_id, anime_id), -- 重复收藏在数据库层就被拒 KEY idx_anime (anime_id) -- 支撑「这部剧被多少人收藏」 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;收藏表只存关系不存计数。很多人会顺手在anime表里加一个fav_count字段点收藏时UPDATE anime SET fav_count fav_count 1。这在小流量下没问题但一旦要处理“取消收藏”或者两个人同一秒点同一个按钮计数就会飘。正确的处理方式在 4.3 节展开。用户表的密码字段必须写清楚存的是加盐哈希不是明文也不是简单 MD5。字段长度给到VARCHAR(100)以上因为 bcrypt 的输出是 60 个字符加上盐和算法标识会超。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL COMMENT bcrypt 输出禁止明文/MD5, nickname VARCHAR(50) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 从 SQLite 切到 MySQL 的时机与三条改造步骤本地开发阶段用 SQLite 是合理的一个文件、零配置、拷走就能跑写完npm start立刻能看页面。但如果你打算在论文里写“索引优化”或“并发控制”这两节SQLite 会很难支撑——它写操作是全库锁讲不出行锁和事务隔离级别实验数据也做不出来。我的建议是需求分析和原型阶段用 SQLite进入正式编码前切 MySQL切换成本大概半天越晚切越疼。切换要做的事只有三件但每一件都有细节。第一步是把建表语句里的方言改掉。SQLite 的AUTOINCREMENT要改成 MySQL 的AUTO_INCREMENTTEXT类型在 MySQL 里如果要加索引必须指定前缀长度DATETIME DEFAULT CURRENT_TIMESTAMP在旧版本 MySQL 里只允许一个列使用。这些差异集中在建表脚本里改一遍就够了。# 三条命令完成环境准备装驱动、改连接串、跑迁移脚本 npm install mysql2 --save # .env 里把 DB_CLIENTsqlite 改成 mysql并补上连接信息 node scripts/migrate.js --envmysql第二步是把驱动换掉。SQLite 时代常用的是同步风格的封装换成mysql2之后所有查询都变成 Promise业务代码里每一处db.query都要补await忘了补就会拿到undefined然后在下一行报错——这是切换期最高频的低级错误。// db.js连接池只创建一次全局复用 const mysql require(mysql2/promise); const pool mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, // 单机演示 10 足够超过数据库反而更慢 charset: utf8mb4, // 必须显式指定否则中文番剧名会变问号 timezone: 08:00 // 避免 created_at 写进去差 8 小时 }); module.exports pool;connectionLimit设成 10 而不是默认值是因为演示机的数据库和 Node 进程挤在同一台机器上连接数开太大只会互相抢 CPU。charset和timezone这两个参数是血泪经验不写utf8mb4番剧名里的特殊符号会变成????不写timezone列表页显示的时间会莫名其妙少 8 小时而你还以为是前端格式化写错了。第三步是检查 SQL 里的隐式类型转换。SQLite 对类型非常宽容WHERE year 2023和WHERE year 2023都能跑MySQL 严格模式下字符串和数字比较会导致索引失效。切换后把所有查询条件过一遍数字字段就传数字别让前端传来的字符串直接进 SQL。3. 后端骨架与前端请求层把接口约定先钉死一个人做全栈最容易犯的错是前后端同时开工写着写着发现字段名对不上然后花一整天改名。避免这个问题的唯一办法是先定接口契约再各自写代码。这一章讲的骨架搭建核心不是“装什么包”而是“怎么用一层薄封装把契约固化下来”让前端的每个请求和后端的每个响应都走同一条通道。3.1 为什么选 Express Vue 而不是服务端模板渲染服务端模板渲染比如模板引擎直接吐 HTML在这个题目上有天然优势没有跨域、没有构建、SEO 友好一个后端就能跑完全站。但它有一个致命问题——论文里不好写“前后端分离”这一节而这恰恰是近几年这类题目最常见的考察点。评审老师关心的不是你的页面多漂亮而是你能不能讲清楚接口怎么设计、状态怎么管理、跨域怎么解决。Express 加 Vue 的组合胜在“薄”。Express 中间件模型简单一个路由文件对应一个资源看完目录结构就知道有哪些接口Vue 加 Vue Router页面和路由一一对应论文里画一张路由表就能讲清系统结构。相比之下用一套重型框架虽然功能全但生成的文件太多答辩时被问到“这个目录是干什么的”答不上来反而扣分。要提醒的是版本选择。国内镜像源上不同版本的包行为差异不小锁版本号比追新重要。在package.json里把主要依赖写成固定版本别用^否则换台机器npm install下来的东西可能和论文里截图的不一样。3.2 目录结构与依赖安装的最小命令后端和前端分两个目录各自独立安装依赖互不干扰。这个结构在论文的“系统总体设计”一章里画成一张树状图非常直观。mkdir anime-site cd anime-site mkdir server client # 后端Express 骨架 MySQL 驱动 认证 跨域 上传 cd server npm init -y npm install express mysql2 jsonwebtoken bcryptjs cors multer dotenv # 前端Vue 脚手架选 Router不选 TypeScript答辩讲解成本低 cd ../client npm init vuelatest npm install axios后端依赖的分工是这样的express是框架本体mysql2提供 Promise 风格的连接池jsonwebtoken负责签发和校验登录凭证bcryptjs做密码哈希纯 JS 实现不需要编译环境换机器不会因为缺少编译工具装不上cors处理开发期跨域multer接收封面图片上传dotenv把数据库密码这类配置从代码里挪出去。前端只装axios因为脚手架已经带了路由和构建工具。目录结构建议按“资源”划分而不是按“技术层”划分server/ routes/ anime.js episode.js user.js comment.js middleware/ auth.js error.js db.js 连接池 app.js 入口挂载路由与中间件 client/ src/views/ Home.vue Detail.vue Search.vue Admin.vue src/api/ request.js anime.js src/router/ index.js按资源划分的好处是论文里写“番剧管理模块”时后端对应routes/anime.js前端对应views/Home.vue和views/Detail.vue一条线能串下来不用在五六个目录里跳。3.3 统一响应体与错误码一个中间件省掉一半联调接口返回格式不统一是联调期最大的时间黑洞。有的接口返回数组有的返回{data: []}有的出错直接返回 500 加一段 HTML 堆栈前端每调一个接口都要写一套新的判断。解决办法是定义一个统一的响应体所有接口都返回它包括出错的时候。// middleware/error.js统一响应 全局错误兜底 function ok(res, data null, msg success) { res.json({ code: 0, msg, data }); } function fail(res, code 1, msg error, httpStatus 200) { // HTTP 状态码保持 200业务错误用 code 区分前端只需判断 code res.status(httpStatus).json({ code, msg, data: null }); } // 兜底任何未捕获的异常都走这里避免把堆栈直接吐给页面 function errorHandler(err, req, res, next) { console.error([UNCAUGHT], req.method, req.originalUrl, err.message); fail(res, 500, 服务器开小差了, 500); } module.exports { ok, fail, errorHandler };参数说明code为 0 表示成功非 0 表示业务失败前端拦截器只看这一个字段就能决定是渲染数据还是弹提示。httpStatus默认给 200 是刻意的选择——浏览器和 axios 对非 2xx 状态会走catch分支业务错误比如“用户名已存在”走catch会让前端代码到处都是 try/catch把业务错误和网络错误混在一起反而不利于排查。真正的服务端异常才用 5xx。这个中间件必须注册在所有路由之后否则它接不到前面的错误。// app.js 片段 const express require(express); const cors require(cors); const { errorHandler } require(./middleware/error); const app express(); app.use(cors({ origin: http://localhost:5173 })); // 只放行本地开发端口 app.use(express.json({ limit: 2mb })); // 限制请求体防止大 payload app.use(/api/anime, require(./routes/anime)); app.use(/api/user, require(./routes/user)); app.use(errorHandler); // 必须最后注册 app.listen(3000, () console.log(api on 3000));express.json的limit参数容易被忽略。默认是 100kb封面图如果走 base64 上传就会超限报PayloadTooLargeError错误信息里看不出是图片太大。设成 2mb 覆盖绝大多数封面场景同时也不至于让一次请求吃掉太多内存。3.4 前端请求封装与登录态守卫前端只写一次请求逻辑所有接口复用它。这样后端哪天改了字段名或者错误码规则只改一个文件。// src/api/request.js import axios from axios; import router from /router; const request axios.create({ baseURL: /api, timeout: 8000 }); // 请求拦截自动带登录凭证 request.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) config.headers.Authorization Bearer ${token}; return config; }); // 响应拦截把 code 非 0 的情况统一处理掉 request.interceptors.response.use( (resp) { const { code, msg, data } resp.data; if (code 0) return data; // 业务代码拿到的直接是 data if (code 401) { // 登录过期 localStorage.removeItem(token); router.push(/login); } return Promise.reject(new Error(msg)); }, (err) Promise.reject(err) // 网络层错误原样抛给调用方 ); export default request;这里的取舍点在于拦截器把data剥出来直接返回业务页面写const list await getAnimeList(params)就够了不用每次.data.data地扒。代价是出错时调用方必须 try/catch不然控制台会有一条未处理的 Promise rejection。功能上不影响但答辩演示时控制台一片红很难看建议在每个页面的一级请求上加catch给一句提示。路由守卫管的是权限/admin开头的路径必须要求role 1否则跳回首页。这个判断不能只在前端做后端每个管理接口也要校验一遍——前端守卫只是体验后端校验才是安全。4. 三个核心功能的实现细节列表筛选、分集播放、收藏计数前面两章把地基和骨架铺好了这一章进到真正会被追问的地方。答辩时老师的问题几乎总落在三个点上列表页的多条件筛选是怎么组合的、播放地址怎么存怎么用、收藏数为什么不会算错。这三个问题答得清楚项目的技术含量就立住了。4.1 列表页多条件筛选的 SQL 拼装与索引四个筛选条件类型、年份、状态、关键词任意组合展开就是 16 种查询形态。写 16 条 SQL 显然不现实正确做法是用参数数组动态拼装WHERE子句。关键点是永远不要把用户输入直接拼进 SQL 字符串而是先收集条件片段和值再交给驱动的占位符处理。// routes/anime.js —— 列表查询 const pool require(../db); async function listAnime({ category, year, status, keyword, page 1, size 20 }) { const where [1 1]; const params []; if (category) { where.push(category ?); params.push(category); } if (year) { where.push(year ?); params.push(Number(year)); } if (status ! undefined status ! ) { where.push(status ?); params.push(Number(status)); } if (keyword) { where.push(title LIKE ?); params.push(%${keyword}%); } const offset (Number(page) - 1) * Number(size); const sql SELECT id, title, cover_url, year, category, status, score FROM anime WHERE ${where.join( AND )} ORDER BY id DESC LIMIT ? OFFSET ? ; const [rows] await pool.query(sql, [...params, Number(size), offset]); const [[{ total }]] await pool.query( SELECT COUNT(*) AS total FROM anime WHERE ${where.join( AND )}, params ); return { list: rows, total }; } module.exports { listAnime };逻辑说明where数组以1 1开头是为了后面无脑AND拼接不用判断“是不是第一个条件”。params和where严格同序增长这是占位符能对上号的唯一保证——顺序错一个报错信息只会说参数类型不匹配排查起来非常痛苦。列表查询和总数查询共用同一套where和params保证分页和总数永远一致。参数说明LIMIT和OFFSET也必须走占位符。MySQL 允许这两个位置直接拼接数字但一旦前端传来sizeabc拼接就会产生语法错误走占位符则会被转成 0 或报类型错误行为可预期。ORDER BY id DESC看似随意其实是分页正确性的前提——没有唯一排序键翻页时会出现重复或漏数据这在第五章会展开讲。索引方面category和year有联合索引title有单列索引。但title LIKE %关键词%这种前置通配符用不上索引会退化成全表扫描。数据量在几千条时感知不到申报材料里如果写“优化了搜索性能”就要老实说明这是前缀匹配的限制或者改用后文 4.4 的方案。4.2 分集播放资源地址存储与三种播放形态播放这块最容易踩的坑不在播放器而在地址怎么存。直接存完整 URLhttp://192.168.1.5:8080/video/1.mp4是新手最常见的做法本地开发完全没问题换台机器或者部署到服务器就全军覆没——所有地址都指向一个不存在的内网 IP。正确的存法是只存相对标识比如ep_no对应的文件名或者干脆只存一个自增 id播放地址由后端按规则拼出来// routes/episode.js —— 按集号返回可播放地址 const path require(path); function buildPlayUrl(ep) { // play_url 里存的是相对路径如 /media/onepiece/003.mp4 const base process.env.MEDIA_BASE || http://localhost:3000; return base ep.play_url; } async function getEpisodes(animeId, req) { const [rows] await pool.query( SELECT id, ep_no, ep_title, play_url, duration FROM episode WHERE anime_id ? ORDER BY ep_no ASC, [animeId] ); // 不把原始相对路径吐给前端统一加工后再返回 return rows.map((e) ({ ...e, play_url: buildPlayUrl(e) })); }MEDIA_BASE从环境变量读本地是http://localhost:3000部署时改成实际域名一处改动全站生效。这就是 2.2 节坚持存相对路径的回报。播放形态按资源类型分三种选哪种取决于素材怎么来。第一种是直链 MP4video src直接播最简单缺点是首屏要等整个文件开始缓冲。第二种是 HLS 切片.m3u8加一堆.ts首屏快、支持拖动但需要用hls.js这类库多一个依赖。第三种是外链嵌入把第三方播放页塞进 iframe省事但不可控页面加载慢、样式无法统一。一个人做论文项目我一般选第一种明确在论文里写“演示环境采用直链 MP4生产环境可平滑替换为 HLS 切片”既诚实又体现了技术判断。提示演示素材请使用自制内容或明确可公开使用的样片不要把来源不明的完整剧集放进项目目录答辩机上的东西是要被看到的。播放器还要处理一个细节切换剧集时如果src变了但组件没销毁浏览器有时会继续播旧的。解决办法是给video加:keycurrentEp.id强制重新挂载。// 记录观看进度每隔 15 秒上报一次避免每秒发请求 let lastReport 0; video.addEventListener(timeupdate, () { const now Date.now(); if (now - lastReport 15000) return; lastReport now; reportProgress(animeId, currentEp.value.ep_no, Math.floor(video.currentTime)); });15000这个阈值是权衡的结果太短会把接口打爆太长则用户中途关页面时进度丢失太多。上报接口内部用INSERT ... ON DUPLICATE KEY UPDATE落库靠(user_id, anime_id)唯一键去重一条 SQL 解决“首次观看插入、再次观看更新”。4.3 收藏与计数冗余字段还是实时聚合收藏本身很简单难的是“这部剧被多少人收藏”这个数字怎么来。两条路实时COUNT(*)聚合或者维护一个冗余计数字段。前者永远准确但有查询成本后者快但有一致性风险。我的建议是列表页用冗余计数详情页用实时聚合。列表页一次要渲染 20 部番剧逐个COUNT就是 20 次子查询慢得肉眼可见详情页只有一条COUNT一次毫无压力。这样既有性能又有准确度。列表页的冗余计数用一个视图或定时聚合脚本来更新而不是在每次收藏时1-- 每 5 分钟刷新一次收藏数避免逐次累加的漂移 UPDATE anime a LEFT JOIN ( SELECT anime_id, COUNT(*) AS cnt FROM favorite GROUP BY anime_id ) f ON f.anime_id a.id SET a.fav_count IFNULL(f.cnt, 0);这段 SQL 的语义是“全量重算”不是“增量累加”。增量累加的错误会一直累积下去而全量重算每次都是从事实表重新数一遍错了下一轮也能自动纠正。几千条数据的情况下这条语句执行时间在毫秒级完全够用。收藏动作本身要走事务因为要同时写收藏表和可选的历史表// 收藏/取消收藏先查后写靠唯一键兜底并发 async function toggleFavorite(userId, animeId) { const conn await pool.getConnection(); try { await conn.beginTransaction(); const [exist] await conn.query( SELECT id FROM favorite WHERE user_id ? AND anime_id ?, [userId, animeId] ); if (exist.length) { await conn.query(DELETE FROM favorite WHERE id ?, [exist[0].id]); } else { // INSERT IGNORE并发下重复插入会被唯一键静默丢弃不会抛错 await conn.query(INSERT IGNORE INTO favorite (user_id, anime_id) VALUES (?, ?), [userId, animeId]); } await conn.commit(); return { favored: exist.length 0 }; } catch (e) { await conn.rollback(); throw e; } finally { conn.release(); // 必须释放否则连接池很快耗尽 } }conn.release()放在finally里是硬性要求。忘了释放连接池 10 个连接用完之后所有请求都会挂起页面表现为“一直转圈”但服务端日志一片安静——这种黑匣子式的故障要排查很久。4.4 关键词搜索LIKE、全文索引与外部检索引擎的分界线搜索是这个项目里最容易被过度设计的地方。三种方案摆在这儿方案实现成本适用数据量能否按相关度排序LIKE %kw%极低万级以下否MySQL 全文索引中十万级是按匹配得分独立检索引擎高百万级以上是毕业论文项目的数据量通常在几百到几千条LIKE完全够用硬上全文索引反而会因为中文分词问题调半天。如果确实想在论文里体现技术深度折中方案是标题用LIKE匹配同时匹配alias别名列两个条件用OR连起来再做一层简单加权——标题命中的排在别名命中前面。SELECT id, title, cover_url, CASE WHEN title LIKE ? THEN 2 ELSE 1 END AS weight FROM anime WHERE title LIKE ? OR alias LIKE ? ORDER BY weight DESC, id DESC LIMIT 20;weight让标题完全或部分命中的结果优先展示用户搜“海贼”时不会先看到一部只在别名里提了一嘴的番剧。这个技巧成本极低但在论文里可以正当地写成“基于字段权重的相关性排序策略”答辩时解释起来也站得住。5. 避坑与排查答辩现场最容易翻车的五个点前面讲的都是“应该怎么做”这一章讲“实际会怎么坏”。这五条是这类项目里反复出现的故障每一条都有明确的触发条件和排查路径。5.1 换台机器图片全裂控制台一片 404现象本地开发一切正常把项目拷到答辩用的笔记本上封面图全部变成裂图标控制台里一堆http://localhost:5173/uploads/xxx.jpg 404。原因图片地址在数据库里存成了带端口的绝对地址或者前端把localhost写死在了img的src前缀里。换机器、换端口地址就失效。更隐蔽的一种是路径大小写Linux 服务器区分大小写Cover/1.jpg和cover/1.jpg是两个文件本地 Windows 上跑得好好的上服务器就找不到。解决数据库只存相对路径前端统一通过一个assetUrl()函数拼接前缀前缀从构建时的环境变量读。文件名全部转小写并去掉空格上传时用Date.now() 随机串重命名避免中文名和特殊字符带来的编码问题。上线前把所有src里出现localhost的地方搜一遍一个不留。5.2 列表翻页出现重复条目或漏掉数据现象第一页有第 1 到 20 条点第二页出现了第一页已经看过的第 15 条而新的第 21 条反复刷新才偶尔出现。原因ORDER BY用的字段不唯一。比如按score DESC排序评分相同的番剧在两次查询之间的相对顺序可能不同数据库没有义务保证稳定排序。OFFSET分页依赖前一次查询的顺序顺序一变页边界就错位。解决排序键末尾永远补一个唯一列写成ORDER BY score DESC, id DESC。同时检查是不是用了LIMIT而漏了OFFSET的单位换算——第二页的offset是(2-1)*20 20写成2*20就会跳过 20 条。5.3 收藏数越跑越多怎么也对不上实际记录数现象后台看某部番剧显示被收藏 47 次但打开收藏表COUNT(*)出来是 39。取消收藏再点一次数字又变大了。原因用了UPDATE ... SET fav_count fav_count 1的增量写法而取消收藏的分支里忘了减 1或者并发下两次请求都读到了旧值再各自加一。这类错误一旦发生就不可逆只能靠人工核对修数据。解决把计数改成全量重算4.3 节那段UPDATE ... JOIN语句挂在定时任务或后台的“刷新统计”按钮上。同时把收藏表上的UNIQUE KEY (user_id, anime_id)补上——很多人的表里没有这个约束同一个人可以收藏同一部剧十次计数当然对不上。加了唯一键之后重复插入会被数据库拒绝问题从根上消失。5.4 为了降重把技术描述改错答辩被当场问穿现象论文里写“系统采用 Redis 缓存热点数据QPS 提升 300%”但代码里一行 Redis 都没有或者把“事务”改写成了“数据回滚机制”被老师追问具体怎么实现的答不上来。原因查重率压下来之后为了继续降重把技术名词和流程描述做了同义替换改着改着就改成了自己都不认识的东西。技术论文的术语是有确定含义的改词等于改事实。解决降重只改句式和语序不动名词。宁可把一个句子的主谓宾重排三遍也不要动“事务”“索引”“外键”这些词。另外论文里出现的每一个技术点都要能在代码里指到具体文件——写之前先在routes/目录里搜一遍确认自己真的用了再写。5.5 答辩机上一跑就报“数据库连接失败”现象在自己电脑上演示流畅换到答辩现场的机器前端页面能打开但列表一直转圈后端日志报ECONNREFUSED 3306。原因数据库服务没启动或者root密码和.env里的配置不一致或者数据库根本没导进去。现场环境往往是刚装好的机器跟开发环境没有任何关系。解决准备一个seed.sql一条命令建库建表加导入演示数据在项目根目录放一个start.sh里面依次拉起数据库、跑迁移、启动后端、启动前端。答辩前在那台机器上完整跑一遍这个脚本跑通了再上台。再稳妥一点提前录一段三分钟的完整演示视频作为兜底现场真出问题时不至于无话可说。6. 把系统映射成论文正文章节对照表与演示兜底系统做完只是走了一半另一半是把代码翻译成文字。这一步的诀窍不是“多写”而是“对齐”——论文的每一章都要能在系统里找到对应的文件反过来每一个核心文件都要能在论文里找到一段说明。我的习惯是先做一张对照表把模块、文件、论文章节、图表编号全部列出来写完一章勾掉一行进度一目了然也不会出现某一章翻遍代码都找不到对应实现的情况。论文章节对应代码位置必须产出的图表需求分析无画用例图用例图、功能模块图系统设计db.js、建表脚本E-R 图、系统架构图、表结构表番剧库实现routes/anime.js、views/Home.vue列表页截图、筛选流程图播放与收藏实现routes/episode.js、favorite表时序图、事务流程图系统测试接口测试脚本测试用例表、响应时间表这张表最有用的一列是“必须产出的图表”。毕业论文的评分里图表质量往往比文字更直观而 E-R 图、架构图、用例图这三张是硬性要求缺了直接扣分。E-R 图用建模工具画别用文字凑架构图画清楚“浏览器—前端应用—后端接口—数据库”四层每条箭头标注协议HTTP、TCP。测试那一章不要写“点击按钮功能正常”这种话。挑三个接口做真实测量把响应时间填进表里比如列表接口在 1000 条数据下平均 12ms、详情接口 8ms、搜索接口 35ms并且说明测试方法循环请求 100 次取平均。数字不用漂亮但必须是真跑出来的因为老师会问“这个数你怎么测的”。演示环节有一个小技巧把演示数据的量级做上去。很多人的项目只有 5 部番剧、10 条评论翻页功能根本展示不出来老师也没法判断分页写得对不对。花半小时写个脚本灌 300 部番剧、2000 条评论列表页一翻就是十几页筛选一勾数据立刻变化演示的说服力完全不同。灌数据的脚本记得不要去改created_at否则按时间排序的演示会露出马脚。我自己的教训是第一次做这类项目时把所有时间都花在了页面上觉得好看就能过。结果答辩时被问“收藏数是怎么保证一致的”我张口结舌只能说“应该是准的”。后来每次做东西我都会在动手前先想清楚“这个功能会被怎么问”答不上来的功能宁可砍掉也别硬塞。系统是给自己讲逻辑用的不是给截图用的。希望帮到你。本文还有配套的精品资源点击获取