这周刚把一个前后端分离的任务看板小系统收尾正好是WEB应用开发设计实训的第三个实验。写实验报告三的时候我自己算了算这个项目里踩的坑比前两个实验加起来都多。实验一还在做纯静态页面实验二开始接触一点JavaScript交互到了实验三突然就要接数据库、搞登录、做接口联调跨度确实不小。这篇就当实验报告三的完整复盘——从需求拆解、技术选型、数据库设计、后端接口实现到前端交互和联调阶段折腾到半夜的几次排错经历全都拆开讲。如果你也正好在准备类似的Web开发实验或者刚把Vue那套跑起来但不知道怎么和后端对接这篇应该能帮你省下不少弯路。1. 实验三要交付什么从需求说明到功能清单1.1 验收标准不是能跑而是可演示实验三的任务书一开始就写得和前面不一样核心要求是数据必须落到数据库里刷新页面之后不能丢。前面两个实验页面长得对、按钮点击有反馈基本就过关了从实验三开始所有的交互都得跟真实数据绑定这决定了整个项目的架构方式。我做的是一个小型任务看板系统功能和市面上的看板工具类似但做了大幅简化用户注册、登录、任务增删改查、任务在待办—进行中—已完成三列之间流转、支持优先级和截止日期标注。验收的时候负责人会按下面这条路径走一遍注册一个新用户然后退出登录用刚注册的账号登录新建一个任务填入标题、优先级、截止日期把任务从待办拖到进行中刷新浏览器确认任务还在进行中这一列退出登录再访问需要登录的页面确认被拦截重新登录任务数据完整恢复这条路径看起来不复杂但每一步背后都对应一条完整的前后端链路。比如刷新后数据还在就要求后端数据库表设计正确、接口返回的数据结构稳定、前端拿到数据之后能正确按状态分组渲染。前两个实验那种写死在页面里的假数据到这里完全不管用了。1.2 我给功能边界画的红线拿到需求之后我第一件事不是翻框架文档而是先把功能边界划清楚。这一步真的很重要不然边做边加需求实验报告根本收不了尾。我当时主动砍掉的功能有四个第一任务描述不做富文本编辑器纯文本就够了。实验的考察重点在数据持久化和状态流转富文本只是锦上添花却会引入大量编辑器组件的配置成本。第二不做拖拽排序的细粒度持久化。也就是说允许任务在列之间拖拽但不记录同一列内任务的先后顺序。这个取舍直接省掉了一张排序字段表也避免了一大堆拖拽位置如何反写数据库的麻烦。第三不做多人协作任务归属只认当前登录用户。每个请求都要求带上Token后端根据Token解析出用户ID所有查询都用这个ID过滤逻辑简单且不容易出错。第四密码找回、邮箱验证这类流程全部不涉及注册登录只做最核心的部分。划完红线之后整个系统的功能清单就非常清晰了注册登录、任务CRUD、状态流转、按优先级和截止日期展示、退出登录。这个范围既能覆盖课程要求的所有技术点又不会因为过度设计把自己拖到报告写不完的境地。2. 环境准备与选型说明这套组合踩出来的经验2.1 开发环境与版本组合选型这件事一开始我纠结了很久。班里有同学用Java的Spring Boot也有用Node.js写Express的我最后选了Python的Flask前端配Vue 3。整个开发环境如下模块选择说明后端框架Flask 2.x轻量路由和请求处理直观能快速聚焦业务逻辑ORMSQLAlchemy配合Flask-SQLAlchemy扩展避免手写SQL数据库SQLite开发/ MySQL提交本地调试用SQLite省心提交时切MySQL验证通用性前端框架Vue 3 Vite组合轻快组件化方式适合看板类交互UI组件库Element Plus表单、按钮、弹窗都有现成封装省下不少样式时间状态管理Pinia管理登录态和用户信息HTTP客户端Axios统一处理请求拦截、响应拦截接口调试Apifox调试接口的同时能生成文档实验报告附件里直接能用选择Flask而不是Spring Boot最直接的原因是时间和资源占用。Spring Boot启动一个项目光依赖下载和配置就要折腾半天而Flask把业务逻辑写清楚只需要很少的模板代码。当然如果你对Java更熟用Spring Boot完全可以后端的接口路径和逻辑设计思路没有任何区别只是把蓝图改成Controller而已。2.2 为什么采用前后端分离而不是服务端页面渲染这个实验要求里有一条是提交接口文档并演示前后端联调过程这就决定了项目组织形式要选前后端分离。从实际开发体验来说任务看板的交互集中在前端的拖拽、状态切换和局部刷新。如果用传统的服务端模板渲染每做一次拖拽都要提交表单并刷新页面体验割裂不说写起来也特别别扭。前后端分离之后前端只关心怎么组织页面状态后端只关心怎么把数据安全地增删改查两边职责清清楚楚。还有一个现实原因实验验收的时候前端是跑在开发服务器上的后端是另一个进程两边通过HTTP接口通信。这种结构最接近当前团队协作的主流方式答辩的时候也更容易把接口设计数据格式约定这些点讲明白。2.3 数据库建模三张表够用了吗我最终设计了三张表用户表、任务表外加一张刷新令牌表。下面这张是任务表的核心结构CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(120) NOT NULL, description TEXT, priority VARCHAR(10) NOT NULL DEFAULT medium, status VARCHAR(10) NOT NULL DEFAULT todo, due_date DATETIME, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) );用户表是标准的id、username、password_hash、created_at结构密码字段只存哈希值这个是底线。刷新令牌表用来处理登录态的续期后面会详细说。这里稍微解释两个设计决策。第一优先级字段我直接用字符串存储取值限定在high、medium、low三个值。有人可能觉得应该建一张优先级字典表用外键关联。这个设计在业务足够复杂时是有意义的但对实验场景来说一张字典表带来的维护成本远大于收益字符串加CHECK约束完全够用。第二任务和用户之间用user_id做外键关联所有查询强制带user_id过滤从数据层就杜绝了看到别人任务的可能比单纯靠接口层判断要稳妥得多。有人会问三张表是不是太少了。最初我也想过要不要加一张操作日志表记录谁在什么时间把任务从A列拖到了B列。后来划功能边界时想明白了日志是审计需求不是业务需求实验验收根本不会看这个加了反而让代码复杂度翻倍。3. 后端接口开发认证、任务流转与异常兜底3.1 注册登录接口与密码哈希的实操细节用户模块是整个系统的基础这个模块出了问题后面所有接口都跟着遭殃。我在这里花的时间最多但收获也最实在。注册接口的处理流程大致是接收JSON请求体校验用户名是否为空、是否已存在校验密码长度然后生成密码哈希写入数据库。下面是密码哈希的核心代码from werkzeug.security import generate_password_hash, check_password_hash class User(db.Model): # ... def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)我刚写的时候想过偷懒直接用MD5加固定盐后来被自己否掉了。实验报告答辩环节一定会问密码怎么存储、如何防撞库如果答案是MD5这个问题基本就答砸了。Werkzeug的generate_password_hash默认使用scrypt算法自带随机盐既简单又能把安全性讲清楚没有必要自己造轮子。登录接口成功后要签发令牌。我用PyJWT生成访问令牌里面存用户ID和过期时间import jwt from datetime import datetime, timedelta, timezone def generate_token(user_id): payload { user_id: user_id, exp: datetime.now(timezone.utc) timedelta(hours2) } return jwt.encode(payload, SECRET_KEY, algorithmHS256)同时生成一个刷新令牌存到数据库过期时间设为7天。前端在访问令牌失效时用刷新令牌请求新令牌避免用户频繁重新登录。这套逻辑在实验里属于加分项但非常实用。3.2 任务管理接口的路径设计与逻辑任务模块是标准的资源型接口路径设计围绕REST风格展开方法路径功能GET/api/tasks获取当前用户任务列表支持status筛选POST/api/tasks创建任务PUT/api/tasks/task_id更新任务标题、描述、优先级、状态、截止日期DELETE/api/tasks/task_id删除任务以更新任务为例我踩过的坑是只校验了任务存在没有校验任务归属。后来每个操作里都加了当前用户判断逻辑如下task db.session.query(Task).filter( Task.id task_id, Task.user_id current_user_id ).first() if not task: return jsonify(code404, message任务不存在, dataNone), 404这个判断必须加在接口第一行。原因是任务ID是自增数字很容易被猜到。如果不做归属校验只要遍历ID就能把别人的任务全改掉这在验收时属于严重安全漏洞。状态流转我做了简化处理没有校验从待办只能到进行中这类状态机规则。一方面实验的核心是数据持久化另一方面状态流转的黑白名单可以放到前端控制后端允许任意合法字符串更新反而更灵活。当然也可以写一个STATUS_TRANSITIONS字典做严格校验但那样会拖慢进度。这个度要看实验要求怎么定。3.3 统一返回包装让前端少写很多判断前后端联调时最怕的就是各写各的前端等一个字段叫message后端返回的是msg这种事一旦发生就得来回改。我在最开始就定了统一的返回结构{ code: 0, message: ok, data: {} }code为0表示成功非0表示各种错误。为了不让自己在写每个接口时都重复拼这个结构我封装了统一的响应函数和全局异常处理器from flask import jsonify def ok(dataNone): return jsonify(code0, messageok, datadata) def fail(code, message, http_status200): return jsonify(codecode, messagemessage, dataNone), http_status app.errorhandler(404) def not_found(e): return jsonify(code404, message接口不存在, dataNone), 404统一结构的好处是前端在Axios的响应拦截器里只需要判断code字段等于0就走成功分支否则统一弹错误提示。不用每个页面都单独处理请求失败的逻辑。我见过不少项目接口返回格式五花八门有的直接返回列表、有的返回对象、有的把错误信息嵌在HTML里前端不光写着累出了问题还分不清是格式问题还是业务问题。4. 前端页面与交互从静态原型到真实联调4.1 登录态管理与路由拦截前端这块首先是登录态管理。我用了Pinia来存token和用户信息同时借助Vue Router的全局前置守卫实现页面访问控制router.beforeEach((to, from, next) { const authStore useAuthStore() if (to.meta.requiresAuth !authStore.token) { next({ path: /login }) } else { next() } })这里遇到一个关键点单纯在路由守卫里判断有没有token还不够因为token可能已经过期。更好的做法是在Axios响应拦截器里统一处理401状态码axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { const authStore useAuthStore() authStore.clear() router.push(/login) } return Promise.reject(error) } )401统一跳登录页的逻辑是联调阶段才真正理解透的。早期我每个接口都单独写错误处理代码大量重复后来才统一到拦截器里处理。这个修改让整个前端代码量减少了大约四分之一而且不会再出现某个页面忘记处理登录过期的情况。4.2 看板三列的数据组织与拖拽更新任务看板的核心是同一份任务数据三种不同视角的展示。后端接口返回的是扁平的任务数组前端需要按status字段分成三组const todoList computed(() tasks.value.filter(t t.status todo)) const doingList computed(() tasks.value.filter(t t.status doing)) const doneList computed(() tasks.value.filter(t t.status done))拖拽交互我用了HTML5原生拖放API没有引入拖拽库。drop事件里拿到拖拽的任务ID和目标状态列然后调用PUT接口更新状态。这里我踩了一个不大不小的坑后面专门讲。另外优先级和截止日期的展示我直接复用Element Plus的标签组件。优先级字段就三个值硬编码好对应的颜色不需要写复杂的渲染逻辑。4.3 联调阶段的传参方式与格式统一前端和后端第一次真正联调出现最多的问题就是传参格式不匹配。我把遇到过的整理成了三类第一类是请求体的格式不统一。Axios默认会把对象序列化成JSON但如果后端接口用request.form取数据就会一直取到空值。解决办法是后端统一从request.get_json()读数据双方约定好所有POST/PUT请求体一律是JSON。第二类是路径参数和查询参数混用。比如获取任务列表有人写成/api/tasks/status/todo有人写成/api/tasks?statustodo。我最后统一按查询参数处理因为status是筛选条件不是资源ID放路径里语义不对。第三类是字段命名风格。数据库字段用了snake_case前端JavaScript习惯用camelCase。我的解决办法是后端接口返回和接收时都坚持snake_case前端在代码里显式写字段名不做自动转换。虽然不如写一个驼峰转换工具那么优雅但胜在直观出问题了一眼就能看到。5. 调试与修复记录三个让我折腾到半夜的问题5.1 跨域放行之后Token成了摆设第一次把前端和后端连起来跑请求直接被浏览器拦了。前端在5173端口后端在5000端口跨域了。我当时的解决方案是用Flask-CORS扩展但第一次配置写得太宽松CORS(app, resources{r/api/*: {origins: *}})配置完发现普通请求能通了但带Token的请求依然失败。看浏览器控制台才发现预检请求OPTIONS返回的响应头里没有Access-Control-Allow-Headers: Authorization。浏览器在预检阶段就发现不允许携带Authorization头直接把后面的真实请求拦下来了根本轮不到后端校验逻辑执行。修复方式是明确指定允许的请求头和来源CORS(app, resources{ r/api/*: { origins: [http://localhost:5173], allow_headers: [Content-Type, Authorization], methods: [GET, POST, PUT, DELETE, OPTIONS] } })这个坑的教训是跨域配置不能无脑用通配符。后端既然要解析Token就必须允许Authorization头通过跨域策略否则就是一个逻辑不通的配置。5.2 日期格式化把截止时间整整吞掉了八小时任务看板里有个截止日期字段前端用户选择2025-04-18 23:59作为截止时间提交保存后刷新页面显示的却是2025-04-18 15:59。整整少了八个小时。排查过程很典型。先看数据库原始存储SQLite里存的是2025-04-18 23:59:00说明后端接收正确、存储正确。再看接口返回的JSON变成了2025-04-18T15:59:00Z。问题出在后端的序列化环节SQLAlchemy默认按UTC时区输出DATETIME字段存储时是本地时间输出时被当成了UTC于是少了八小时。这个问题的教训是前后端传输日期时间永远不要依赖对方去猜时区。我最后的处理方式是后端在返回字段时统一格式化为不带时区的字符串yyyy-MM-dd HH:mm:ss前端拿到字符串直接展示不调用Date自动解析。跨时区转换不是实验需要研究的重点能避免就尽量避免。5.3 并发点击下任务看板越界的修复任务看板跑通之后出现了一个隐蔽问题快速连续点击拖拽时任务状态偶尔会错乱。比如一个任务刚从待办拖到进行中紧接着用户又快速点了另一个操作前端发出的两个请求到达后端的顺序和发出顺序不一致最后数据库里存的状态和用户看到的状态不一致。问题根因是任务更新时没有做任何并发控制。我的修复方案是在更新接口里加入乐观锁判断利用updated_at字段作为版本号result db.session.query(Task).filter( Task.id task_id, Task.user_id current_user_id, Task.updated_at request_json.get(updated_at) ).update({ title: request_json.get(title), status: request_json.get(status), updated_at: datetime.now() }) if result 0: return jsonify(code409, message数据已在其他地方被修改请刷新页面, dataNone), 409对应的前端逻辑是拖拽任务时先拿到任务当前的updated_at值一起提交如果收到409响应说明本次修改基于的数据已经过期立刻重新拉取任务列表覆盖本地状态。这套机制不加也能跑但加完之后快速连续操作再也不会出现状态错乱答辩时还能往并发控制这个方向多聊几句。5.4 实验验收的完整过程和最终效果修复完上面三个问题之后整个系统的稳定性才算真正达标。验收前我按最初的需求清单完整走了一遍流程注册新用户、退出登录、重新登录、新建任务、拖拽流转、刷新页面数据保留、退出后访问受限接口被拦、重新登录数据恢复。每一步都在预期内没有出现需要刷新两次才能看到正确数据的情况。完成之后我自己最大的感受是实验三真正考察的不是某个框架的API背诵而是把前端页面、后端接口、数据库存储串成一条完整链路的能力。之前学的东西都是孤立的到了这个实验才第一次有了一个请求从前端组件出发穿过网络、经过后端校验、写入数据库、再原路返回渲染成页面的整体感。最后分享一个小组内的排查技巧联调阶段不要盯着浏览器页面猜问题直接把接口调用的完整请求和响应打出来用Aplayer这类调试工具看原始报文基本上百分之八十的问题出在某个字段名写错、某个请求头没带上这类低级错误。把联调流程固定下来后面的实验会轻松很多。