羽毛球圈的球友组织活动之前一直靠微信群接龙消息一多就乱、报名状态靠人工盯、场地信息散在各个群里。我做了个小而全的解决方案用 Django 做后端、Vue 做前端搭了一个羽毛球交流平台。这个项目麻雀虽小但五脏俱全涉及用户登录、活动发布、组队报名、场地管理、实时消息推送这些典型场景。这篇文章把我从设计到实操踩过的坑完整捋一遍适合刚学完 Django 基础、想找一个完整项目练手的前端同学也适合已经在做前后端分离项目、想看看别人怎么处理联调问题的朋友。现在的社区类项目技术栈无非就是那几套为什么我最终定了 Django Vue一句话说清楚Django 的 ORM 和管理后台能让我在最短时间内把业务模型跑起来Vue 的组件化开发则让前端页面维护起来不痛苦。前后端分离的架构下两边可以独立开发、独立测试后面我要加移动端 H5 或者小程序后端接口可以直接复用。先说项目背景。羽毛球圈子有个真实痛点约球信息散落、报名统计麻烦、临时取消通知不到位。那么一个交流平台至少要解决这几个问题用户注册登录后能看到附近球局、可以发布新活动、能一键报名/取消、活动状态变化要实时通知到场人员。再加上一个基础的球友留言区让不在一起打球的人也能交流技术心得或者找固定球搭子。后端我用 Django 3.2 Django REST Framework前端用 Vue 3 Vite Element Plus数据库用的 MySQL缓存和实时推送用 Redis。这套选型不是最潮的但每一环都稳定社区资料也多遇到问题基本都能搜到答案。1. 项目整体设计与思路拆解1.1 核心业务模块梳理我把整个平台拆成了五个模块每个模块对应一组数据模型和一组接口用户模块注册、登录、个人资料、我的活动列表。活动模块活动发布、活动列表、活动详情、报名/取消报名。场地模块场地信息管理、场地空闲状态。消息模块站内通知、WebSocket 实时推送。交流模块球友留言、帖子发布。为什么不做一个大而全的社区因为羽毛球交流平台的核心价值是约球 找人社交属性应该围绕活动展开而不是做成一个泛论坛。所以留言区我刻意做得轻量只有简单的发帖和回帖不做私信、不做好友关系这样后端逻辑不膨胀开发周期能控制住。1.2 前后端分离的架构选择前后端分离是这几年做 Web 项目的默认姿势但很多人只知其然不知其所以然。我实际对比过传统 Django 模板渲染和前后端分离两种方案最终选分离的原因有三个第一模板渲染的项目后期改版成本高。做活动列表页的时候我可能只想改一个排序逻辑但因为模板和业务代码耦合一个改动牵连好几个文件。分离之后前端只管调接口渲染数据后端只管接口逻辑职责边界清清楚楚。第二移动端复用接口。羽毛球球友很多在场地上直接掏手机看信息后续大概率要做 H5 或者小程序。前后端分离模式下接口写好之后多端共用一套不用像模板渲染那样重写一遍后端逻辑。第三团队协作效率。哪怕你现在是单人开发分离之后你也能在启动 Vue 的开发服务器时同时调试 Django 接口。我用 Vite 的 proxy 把/api请求转发到 Django 的 8000 端口开发时两边都不用管跨域。1.3 为什么选 Django 而不是其他后端框架我承认 Node 的 Express、Python 的 FastAPI 都能干这活但 Django 在这类业务型项目里有几个别人替代不了的优势自带 Admin 后台。球场管理员可以登录 Django admin 直接维护场地数据不需要我先做一个管理页面。这个对个人开发者的意义很大省掉一个完整的前端管理平台。ORM 写复杂查询省力。羽毛球活动需要按地理位置、时间、人数筛选Django ORM 的filter()链式调用配合Q对象几行代码就能组合出复杂的查询条件。生态成熟。认证、权限、分页、序列化Django REST Framework 全都内置了最佳实践。我做 JWT 登录用的djangorestframework-simplejwt做 WebSocket 用的channels都是经过大规模项目验证的方案。如果项目规模再大两个量级我可能会考虑 FastAPI 的异步性能优势但对于一个羽毛球交流平台这种业务密集型项目大部分时间花在数据库查询和业务判断上Django 的成熟稳定反而是更重要的属性。2. 后端核心细节数据模型、认证与实时通信2.1 数据模型设计三张核心业务表写任何项目我习惯先把数据模型想清楚。这个平台我用五张表支撑核心流程User复用 Django 自带的、Venue场地、Activity活动、Enrollment报名记录、Message站内消息。其中活动表是最关键的它长这样from django.db import models from django.contrib.auth.models import AbstractUser class Venue(models.Model): name models.CharField(场地名称, max_length100) address models.CharField(详细地址, max_length255) court_count models.IntegerField(场地数量, default1) price_per_hour models.DecimalField(每小时价格, max_digits6, decimal_places2) class Activity(models.Model): STATUS_CHOICES [ (recruiting, 招募中), (full, 已满员), (in_progress, 进行中), (finished, 已结束), (cancelled, 已取消), ] title models.CharField(活动标题, max_length100) venue models.ForeignKey(Venue, on_deletemodels.CASCADE, related_nameactivities) organizer models.ForeignKey(auth.User, on_deletemodels.CASCADE, related_nameorganized_activities) start_time models.DateTimeField(开始时间) end_time models.DateTimeField(结束时间) max_players models.IntegerField(最大人数, default12) current_count models.IntegerField(当前报名人数, default0) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultrecruiting) description models.TextField(活动说明, blankTrue) created_at models.DateTimeField(auto_now_addTrue)这里有几个我一开始没注意、后来吃了亏的设计细节current_count这个字段是一个故意冗余的设计。按理说当前报名人数可以通过统计Enrollment表得到但如果每次前端显示活动列表都要annotate一下报名人数活动一多查询就会明显变慢。用冗余字段存一个计数报名时F()表达式原子加一列表接口直接查这个字段性能好很多。status我用了字符串而不是外键因为活动状态是固定的枚举值用choices足够了。数据库里存recruiting这种可读字符串调试的时候看数据一目了然不用去翻关联表。还有一个关键点是on_deletemodels.CASCADE。当场地被删除时关联的活动也删掉避免因为外键约束导致程序报错。实际开发中如果平台已经有了大量真实活动你可能希望改成PROTECT不允许删场地但开发前期CASCADE省心。2.2 Django ORM 的高频操作实战这个项目的接口基本就是增删改查但查询这个事值得多聊几句。羽毛球活动的筛选条件特别典型用户要看今天有哪些活动离我近的场地还没满员的活动。我封装了一个查询函数from django.db.models import Q, F from django.utils import timezone def get_available_activities(dateNone, venue_idNone): 获取可报名的活动支持按日期和场地筛选 queryset Activity.objects.select_related(venue, organizer).filter( status__in[recruiting, in_progress], start_time__gttimezone.now(), ) if date: queryset queryset.filter(start_time__datedate) if venue_id: queryset queryset.filter(venue_idvenue_id) return queryset.order_by(start_time)重点看select_related。活动列表接口返回的数据里包含场地名称和组织者昵称如果不加这个Django ORM 会对每个活动额外发起一次场地查询和一次用户查询一个 20 条数据的列表就是 41 条 SQLN1 查询是新手最容易踩的坑。加了select_related之后变回一条 JOIN 查询性能差距在数据量小的时候看不出来但页面响应速度的体感差异是实实在在的。删除对象的操作也要小心。羽毛球活动结束后我写了定期清理脚本# 清理已结束超过7天的活动连带删除报名记录 expired_activities Activity.objects.filter( statusfinished, end_time__lttimezone.now() - timezone.timedelta(days7) ) expired_count, _ expired_activities.delete()注意delete()方法是 QuerySet 级别的Activity.objects().filter().delete()会把关联的外键也按on_delete规则处理。但如果你的Enrollment表数据量大这个操作可能锁表影响线上业务稳妥做法是分批删from itertools import islice ids list(expired_activities.values_list(id, flatTrue)) # 每次删 200 条避免一次锁太多行 for batch_start in range(0, len(ids), 200): batch_ids ids[batch_start:batch_start 200] Activity.objects.filter(id__inbatch_ids).delete()2.3 JWT 认证与权限控制用户模块我直接用了 JWT。这里要说明一下Django REST Framework 自带的SessionAuthentication其实也很好用但前后端分离项目用 JWT 更贴合场景——前端把 token 存在 localStorage 或者内存里每次请求带在Authorization头后端无状态验证不需要维护 session 记录。安装djangorestframework-simplejwt之后配置非常简单# settings.py REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), DEFAULT_PERMISSION_CLASSES: ( rest_framework.permissions.IsAuthenticated, ), } from datetime import timedelta SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), }关于 token 有效期我的建议是 access token 设短一点1~2小时refresh token 设长一点7~14天。每次前端发现 access token 过期就拿 refresh token 去换新的用户无感知。很多新手图省事把 access token 设成 7 天一旦泄露等于永久通行证这是安全大忌。权限控制方面Django 的 ModelViewSet 配合权限类是常规操作。发布活动必须登录修改和删除活动只有组织者本人能做from rest_framework.permissions import BasePermission class IsOrganizerOrReadOnly(BasePermission): def has_object_permission(self, request, view, obj): if request.method in SAFE_METHODS: return True return obj.organizer request.userSAFE_METHODS是 GET/HEAD/OPTIONS 这些只读方法任何登录用户都能访问写操作就校验是不是组织者本人。这个权限类的逻辑很清爽也符合真实业务你可以看别人的活动但不能改别人的活动。2.4 Django Channels 实现 WebSocket 实时推送活动中有人报名、有人取消、活动被取消了这些事件都需要实时通知相关用户。轮询当然也能做但体验很差——每 5 秒发一次请求数据库压力大信息延迟又高。WebSocket 是更合适的方案长连接建立后服务端可以主动往客户端推数据。Django 里做 WebSocket 需要引入channels它把 Django 从同步 HTTP 世界扩展到异步协议层。核心逻辑是写一个消费者# consumers.py import json from channels.generic.websocket import AsyncWebsocketConsumer class ActivityNotificationConsumer(AsyncWebsocketConsumer): async def connect(self): self.user self.scope[user] if self.user.is_authenticated: # 每个用户一个独立分组只推给自己的群 self.group_name fuser_{self.user.id} await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() else: await self.close() async def disconnect(self, close_code): if hasattr(self, group_name): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def send_notification(self, event): await self.send(text_datajson.dumps({ type: event[notify_type], message: event[message], activity_id: event[activity_id], created_at: event[created_at], }))业务后端在某个动作发生时通过channel_layer.group_send往用户分组里发消息。这里最关键的配置是 Redis 作为 channel layer 的后端因为多个 Django worker 进程之间需要共享连接信息# settings.py CHANNEL_LAYERS { default: { BACKEND: channels_redis.core.RedisChannelLayer, CONFIG: {hosts: [(127.0.0.1, 6379)]}, }, }WebSocket 部署的时候还有个坑你不能只用runserver生产环境需要daphne或者uvicorn启动 ASGI 应用配合 Redis 做 channel layer否则多进程下消息会乱。3. Vue 前端工程化从环境搭建到业务落地3.1 Vite 构建 Vue 3 项目前端部分我用 Vue 3 Vite不用官方 Vue CLI。原因很简单Vite 基于原生 ESM开发服务器启动是秒级热更新也快。Vue CLI 基于 Webpack配置能力强大但冷启动确实慢对于这个体量的项目个人认为 Vite 的效率收益更明显。初始化项目npm create vuelatest选好 TypeScript、Vue Router、状态管理我用的 Pinia之后把依赖装上npm install npm install axios element-plus这个脚手架会生成标准的src/views、src/components、src/router目录结构。我第一次做 Vue 项目时习惯把所有页面都堆在views里组件库、业务组件、页面组件混在一起后来前端文件多了活动列表、活动详情、发布页、个人中心、留言板光是找文件就能花半分钟。现在我的习惯是每个业务模块一个文件夹src/views/activity/下面只放页面通用组件丢src/components/接口请求统一放src/api/。3.2 路由设计与权限守卫羽毛球交流平台的路由分两块公开页面活动列表、活动详情、留言板和需要登录的页面发布活动、个人中心。Vue Router 的 4.x 版本写法// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: home, component: () import(/views/activity/ActivityList.vue) }, { path: /activity/:id, name: activity-detail, component: () import(/views/activity/ActivityDetail.vue), props: true }, { path: /publish, name: publish, component: () import(/views/activity/ActivityPublish.vue), meta: { requiresAuth: true } }, { path: /profile, name: profile, component: () import(/views/user/UserProfile.vue), meta: { requiresAuth: true } }, { path: /forum, name: forum, component: () import(/views/forum/ForumList.vue) }, ] const router createRouter({ history: createWebHistory(), routes, }) router.beforeEach((to, from, next) { const token localStorage.getItem(access_token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })这里有个易错的细节localStorage.getItem(access_token)只能判断 token 是否存在不能判断是否过期。严谨的做法是前端在请求 401 时自动跳登录页配合 axios 拦截器把token 是否有效的判断交给后端前端只管有没有 token这个粗粒度判断。登录后跳回原始页面的逻辑也很关键我用query: { redirect: to.fullPath }记录用户想去哪里登录成功之后router.push(route.query.redirect)回跳体验就顺滑了。动态路由是另一个话题。如果平台要做会员等级普通球友、球场管理员、平台运营不同角色能访问的页面不同那就需要从后端拉取权限配置用router.addRoute()动态注册。我目前没有做复杂的角色体系管理员直接走 Django admin所以暂不需要但路由设计的结构要为后面扩展留出余地。3.3 axios 封装与 Vue 组件封装请求封装是前后端联调的第一道关卡。我统一用了 axios 实例把 baseURL、超时时间、请求拦截器、响应拦截器都集中管起来// src/api/request.js import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000, }) // 请求拦截器自动带上 token request.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { if (error.response) { if (error.response.status 401) { localStorage.removeItem(access_token) localStorage.removeItem(refresh_token) router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } else { ElMessage.error(error.response.data.detail || 请求失败请稍后重试) } } else { ElMessage.error(网络异常请检查网络连接) } return Promise.reject(error) } )前端组件封装方面我用 Element Plus 做基础 UI但业务组件自己封装了一层。比如活动卡片ActivityCard.vue接收一个 activity 对象展示时间、场地、人数、状态点击跳详情。这个组件在活动列表页和个人中心都会用到封装一次后面都省事。羽毛球活动的报名状态用一个彩色 Tag 展示——招募中是绿色、已满员是橙色、已取消是灰色——这些视觉细节虽然不起眼但用户浏览列表时的信息获取效率完全不同。3.4 与后端 API 对接的细节对接接口时Vue 这边最需要注意的是数据结构的约定。DRF 默认返回的字段名是 Python 风格的snake_case比如max_players、start_time前端保持原样使用就行不要多做一层转换。很多新手会写映射函数把字段名改成驼峰这是多余的反而是给自己埋坑。上传图片比如用户头像、活动宣传图的时候注意 Django 的MEDIA_ROOT配置和前端提交格式// 上传图片 const formData new FormData() formData.append(avatar, file) request.post(/users/me/avatar/, formData, { headers: { Content-Type: multipart/form-data } })axios 在传 FormData 的时候千万不要手动设Content-Type: application/json如果设错了后端解析不了文件而且前端浏览器会一直报 400。正确的做法是让浏览器自动带上multipart/form-data; boundary...像上面代码里显式写headers其实也多余axios 检测到 FormData 会自动处理不写反而最稳。4. 前后端联调与 WebSocket 对接实录4.1 开发环境的跨域与代理配置开发阶段最典型的问题就是跨域。我的代码架构是这样的Vue 跑在 5173 端口Django 跑在 8000 端口浏览器访问 Vue 页面时页面的请求地址是http://localhost:5173/api/activities指向 Vue 服务器根本没有 Django 的事。解决办法是 Vite 的 proxy 把/api开头的请求转发到http://localhost:8000// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8000, changeOrigin: true, }, /ws: { target: ws://localhost:8000, ws: true, }, }, }, })changeOrigin: true的意思是修改请求头里的Host为 target 的地址否则 Django 的ALLOWED_HOSTS校验会拒绝来自localhost:5173的请求。WebSocket 的代理单独配ws: true是关键。跨域的另一种做法是后端配django-cors-headers但那只适用于生产环境前后端域名不同的情况。开发阶段用 Vite proxy 更清爽不用管 CORS 预检请求而且修改后端接口地址只需要改一处 proxy 配置前端代码一行都不用动。4.2 WebSocket 前端的连接管理WebSocket 前端不能用 axios得用浏览器原生 WebSocket API 或者封装好的库。我写了一个简单的连接管理模块// src/utils/websocket.js class WSManager { constructor() { this.socket null this.reconnectAttempts 0 this.heartbeatTimer null } connect() { const token localStorage.getItem(access_token) if (!token) return const protocol window.location.protocol https: ? wss : ws this.socket new WebSocket(${protocol}://${window.location.host}/ws/notifications/?token${token}) this.socket.onopen () { this.reconnectAttempts 0 // 心跳保活每 30 秒发一次 ping this.heartbeatTimer setInterval(() { if (this.socket.readyState WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: ping })) } }, 30000) } this.socket.onclose () { clearInterval(this.heartbeatTimer) if (this.reconnectAttempts 5) { setTimeout(() this.connect(), 3000 * (this.reconnectAttempts 1)) this.reconnectAttempts } } this.socket.onmessage (event) { const data JSON.parse(event.data) // 这里触发一个全局事件让组件响应 window.dispatchEvent(new CustomEvent(notification, { detail: data })) } } } export const wsManager new WSManager()WebSocket 的 token 怎么传我用了 URL query 参数因为浏览器的 WebSocket API 不支持自定义 Header。但这里有个安全细节token 会出现在服务端访问日志里生产环境建议用短期有效的专用 token或者建立连接后第一条消息里鉴权连接先不算完成。我这个项目规模不大query 传参配合 wss 加密传输可以接受但如果敏感程度高的项目建议第一条消息鉴权的方案。前端的重连逻辑必须做。羽毛球活动场景里用户可能在地铁上信号中断、手机锁屏后网络切换WebSocket 连接说断就断。我做的是指数退避重连第一次重连等 3 秒第二次等 6 秒最多 5 次避免高频重连把服务端压垮。4.3 联调中的接口规范与调试技巧前后端联调最容易出的问题就是数据格式对不上。我的做法很简单后端写接口之前先定义一份接口文档不需要什么 Swagger先就关键字段对齐活动列表接口返回{ id, title, venue_name, start_time, end_time, max_players, current_count, status }前端照着这个结构写界面和类型定义。DRF 的序列化器天然负责这个输出格式但字段命名一定要在后端开发时就和前端确认好不要先返回再说。调试接口我用的是浏览器 DevTools 的 Network 面板加 vue-devtools。Network 面板能直观看到每个请求的 URL、请求头、响应体接口报错了先看响应状态码400 是参数校验失败、401 是没带 token 或 token 过期、403 是权限不足、404 是路径写错了。分清这四种状态码排查速度快一倍。Django 后端这边我强烈建议配django-debug-toolbar它能列出每个请求执行的 SQL 语句数量和时间。我看一个列表接口慢打开 debug toolbar 一看发现 N1 查询没处理干净select_related没加对SQL 执行了 37 次。修完再看变成一条 SQL响应时间从 400ms 降到 60ms。这个工具对排查性能问题是神器级的存在。5. 部署上线与常见问题速查5.1 生产环境部署方案开发完以后要上线我用的是 Nginx Gunicorn Daphne MySQL Redis 这套组合。Nginx 负责静态文件、反向代理和 SSL 终止Gunicorn 跑 Django 的 WSGI 接口Daphne 跑 Channels 的 ASGI 接口Redis 既做缓存又做 WebSocket 的 channel layer。一个容易踩的坑是HTTP 和 WebSocket 要分两套入口。Nginx 配置大致是server { listen 80; server_name your-domain.com; location /static/ { alias /var/www/badminton/static/; } location /media/ { alias /var/www/badminton/media/; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /ws/ { proxy_pass http://127.0.0.1:8001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; } }注意/ws/这段配置里Connection: upgrade必不可少没有这个头WebSocket 握手会直接失败。另一个注意点是 Django 的ALLOWED_HOSTS一定要配置成你的域名否则生产环境访问会报DisallowedHost。前端部署更简单npm run build生成dist/目录Nginx 把它指向一个 location 作为静态站点。需要注意 Vue Router 的history模式刷新页面时 Nginx 会去服务器找不存在的路径需要在 Nginx 里加try_files $uri $uri/ /index.html否则用户点进活动详情页后按 F5 就会收到 404。5.2 实战中遇到的高频问题汇总前前后后开发加调试我攒了一份问题和解决方法的清单这里挑几个最有代表性的问题现象根本原因解决办法前端请求接口报 403 CSRF 错误DRF 的 SessionAuthentication 会校验 CSRF前后端分离下容易误触发使用 JWT 认证替代 SessionAuthentication或者在 ViewSet 上标记csrf_exempt不需要的视图活动列表加载慢忘记select_related每个活动的场地和用户额外查询ORM 链式调用里加select_related(venue, organizer)报名人数不准多个用户并发报名时读旧值再 1用F(current_count) 1原子更新别先查再更新WebSocket 连接秒断Nginx 没有配置 Upgrade 头或者连接 60 秒无消息被网关断开配置Connection: upgrade 前端 30 秒心跳刷新页面 404Vue Router history 模式没有 fallbackNginxtry_files $uri $uri/ /index.html图片上传 400axios 手动设置了Content-Type: application/json去掉 headers 配置让浏览器自动处理 FormData活动发不了偶发报错表单里时间格式不对Django 解析不了前端用dayjs格式化时间统一传YYYY-MM-DDTHH:mm格式关于并发报名这里再展开一下。两个用户同时看到活动还剩最后一个名额同时点了报名。如果后端代码是查一下当前人数小于最大人数就插入报名记录再 1那么两个请求都能通过校验最后人数超了。正确做法是把判断和更新放在一个原子操作里from django.db import transaction transaction.atomic def enroll_activity(user_id, activity_id): activity Activity.objects.select_for_update().get(idactivity_id) if activity.current_count activity.max_players: raise ValueError(活动已满员) Enrollment.objects.create(user_iduser_id, activity_idactivity_id) Activity.objects.filter(idactivity_id).update(current_countF(current_count) 1)select_for_update在事务里给活动行加锁两个并发请求会排队执行第二个请求进来时发现人数已满直接拒绝。这是数据库层面解决并发问题的标准姿势。业务量小的项目这招够用了如果量大再做 Redis 分布式锁。还有个容易被忽略的点清理活动状态。活动开始时间到了状态应该从招募中变成进行中活动结束变成已结束。我写了一个 Django management command用 Celery 定时任务每小时跑一次用 Django ORM 批量更新状态不用人工盯。5.3 优化空间与后续演进方向这个羽毛球交流平台做到现在核心流程已经完整跑通了。如果继续往下做我有几个明确的演进方向第一接入地图选场。羽毛球爱好者最关心的其实是离我多远。目前场地只有文本地址后续可以接地图组件用坐标做距离排序让用户按地理位置找场地。第二WebSocket 的扩展。现在只做了有人报名/取消的通知推送。真正打起来可以做到活动开始前 30 分钟自动给所有人推送提醒用户关注某个场地后新活动发布立刻推送。这些都需要调度任务配合 WebSocket。第三数据可视化。后台管理如果不想用 Django admin 的原始界面可以做一个统计面板展示活跃用户数、最热门场地、每周活动场次数。这些数据端的支撑可以先通过增加几个聚合接口来提供。第四移动端 H5 适配。羽毛球用户很多在球场临时约人手机是主要终端。当前 Vue 3 的项目可以加上移动端响应式适配或者单独做一个 H5 版本。不是所有功能都要在第一个版本做出来。我的原则是先把核心链路发布活动 → 用户报名 → 到场打球 → 消息通知做得足够顺滑再考虑增强功能。很多个人项目死在想做的太多、做出来的太少先砍需求、保住核心闭环比一步到位更现实。最后说句题外话我在开发过程中感受最深的是前后端分离项目真正考验人的不是某个具体技术而是接口契约的把握。前端和后端是两个人哪怕时间上可能是一个人只要接口字段、状态码、错误处理这些约定做得足够细致联调阶段就不会互相等来等去。我吃过的亏都是我以为后端会返回这个字段和我以为前端会传那个参数这种沟通断档造成的。所以我的建议是动手写代码之前先花半小时把接口文档的字段从头到尾对齐一遍把异常情况说清楚。这半小时的投入会在后面省出几天的联调时间。