最近花了两个月时间从零搭建了一套完整的高铁票在线购票系统(毕设+练手项目),前端用Vue,后端试过Flask后来换成了Django,踩了不少坑——比如一开始没考虑并发导致“超卖”,爬12306车次数据被反爬,支付回调处理不及时导致订单状态混乱。这篇文章就把整个项目的技术选型、核心功能实现、难点解决方案全拆出来,不管是做毕设还是想练手全栈项目的同学,看完应该能少走一半弯路。
一、技术选型:为什么选Vue+Django,而不是Flask?
一开始后端选的是Flask,因为轻量上手快,但做到购票下单模块时发现:Flask的扩展虽然多,但需要自己拼积木(比如权限控制要装Flask-Login,ORM用SQLAlchemy),而Django自带的Admin、ORM、Auth系统太香了,尤其做后台管理和用户认证时省了大量代码。最后决定后端用Django,前端Vue,搭配MySQL+Redis(缓存余票),具体选型如下:
| 技术栈 | 选型理由 |
|---|---|
| 前端 | Vue 3 + Vue Router + Vuex + Element Plus |
| 后端 | Django 4.2 + Django REST Framework (DRF) |
| 数据库 | MySQL + Redis |
| 异步任务 | Celery + Redis |
| 部署 | Docker + Nginx + Gunicorn |
这里插一句:如果是做小Demo,Flask足够,但涉及到用户认证、权限管理、复杂业务逻辑,Django的“开箱即用”优势太明显了——比如DRF的Token认证、分页、过滤,几行配置就能搞定。
二、核心模块实现:从车次查询到购票下单
1. 车次与余票模块:数据爬取+实时缓存
首先得解决“车次数据从哪来”的问题。一开始想直接调用12306的API,但官方没开放,只能自己模拟爬取(仅用于学习,请勿商用)。用+
requests爬取车次信息,再用定时任务更新到MySQL,余票则用Redis做实时缓存(因为余票查询是高频操作,直接查MySQL会慢)。
BeautifulSoup
(1)Django模型设计(车次+余票)
# models.py
from django.db import models
class Train(models.Model):
"""车次信息模型"""
train_no = models.CharField(verbose_name="车次号", max_length=20, unique=True)
start_station = models.CharField(verbose_name="出发站", max_length=30)
end_station = models.CharField(verbose_name="到达站", max_length=30)
start_time = models.TimeField(verbose_name="出发时间")
end_time = models.TimeField(verbose_name="到达时间")
duration = models.DurationField(verbose_name="历时")
price = models.DecimalField(verbose_name="票价", max_digits=10, decimal_places=2)
create_time = models.DateTimeField(verbose_name="创建时间", auto_now_add=True)
update_time = models.DateTimeField(verbose_name="更新时间", auto_now=True)
class Meta:
verbose_name = "车次"
verbose_name_plural = verbose_name
class TicketStock(models.Model):
"""车票余票模型"""
train = models.OneToOneField(Train, on_delete=models.CASCADE, verbose_name="关联车次")
total_tickets = models.IntegerField(verbose_name="总票数", default=100)
remaining_tickets = models.IntegerField(verbose_name="剩余票数", default=100)
update_time = models.DateTimeField(verbose_name="更新时间", auto_now=True)
class Meta:
verbose_name = "车票余票"
verbose_name_plural = verbose_name
(2)Redis缓存余票(Python端)
# utils/redis_utils.py
import redis
from django.conf import settings
redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True
)
def cache_ticket_stock(train_no, remaining_tickets):
"""缓存车次余票"""
redis_client.set(f"ticket:stock:{train_no}", remaining_tickets)
redis_client.expire(f"ticket:stock:{train_no}", 60) # 1分钟过期,定时任务更新
def get_ticket_stock(train_no):
"""获取缓存的余票,无则查DB并缓存"""
stock = redis_client.get(f"ticket:stock:{train_no}")
if not stock:
from apps.ticket.models import TicketStock, Train
try:
train = Train.objects.get(train_no=train_no)
ticket_stock = TicketStock.objects.get(train=train)
stock = ticket_stock.remaining_tickets
cache_ticket_stock(train_no, stock)
except (Train.DoesNotExist, TicketStock.DoesNotExist):
return 0
return int(stock)
(3)前端Vue查询组件(核心代码)
<!-- src/views/TicketQuery.vue -->
<template>
<div class="ticket-query">
<el-form :model="queryForm" inline @submit.prevent="queryTickets">
<el-form-item label="出发站">
<el-input v-model="queryForm.startStation" placeholder="如:北京西"></el-input>
</el-form-item>
<el-form-item label="到达站">
<el-input v-model="queryForm.endStation" placeholder="如:上海虹桥"></el-input>
</el-form-item>
<el-form-item label="出发日期">
<el-date-picker v-model="queryForm.date" type="date" placeholder="选择日期"></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="queryTickets">查询车票</el-button>
</el-form-item>
</el-form>
<el-table :data="ticketList" border>
<el-table-column prop="trainNo" label="车次"></el-table-column>
<el-table-column prop="startStation" label="出发站"></el-table-column>
<el-table-column prop="endStation" label="到达站"></el-table-column>
<el-table-column prop="startTime" label="出发时间"></el-table-column>
<el-table-column prop="endTime" label="到达时间"></el-table-column>
<el-table-column prop="price" label="票价(元)"></el-table-column>
<el-table-column prop="remainingTickets" label="剩余票数"></el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button type="primary" size="small" @click="buyTicket(scope.row)" :disabled="scope.row.remainingTickets <=0">购票</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import axios from '@/utils/request'
const queryForm = ref({
startStation: '',
endStation: '',
date: ''
})
const ticketList = ref([])
const queryTickets = async () => {
if (!queryForm.value.startStation || !queryForm.value.endStation || !queryForm.value.date) {
ElMessage.warning('请填写完整查询条件')
return
}
try {
const res = await axios.get('/api/ticket/query', {
params: queryForm.value
})
ticketList.value = res.data.data
} catch (err) {
ElMessage.error('查询失败:' + err.message)
}
}
const buyTicket = (ticket) => {
// 跳转到购票页面,传递车次信息
router.push({
path: '/buy-ticket',
query: { trainNo: ticket.trainNo }
})
}
</script>
2. 购票下单模块:解决“超卖”的核心——乐观锁
一开始做购票功能时,直接先扣减余票再创建订单,结果测试时开两个浏览器同时下单同一车次,出现了“余票为负”的超卖问题。后来查资料才知道,需要用乐观锁来保证并发安全(因为购票场景是“读多写少”,乐观锁比悲观锁性能好)。
(1)Django后端购票接口(带乐观锁)
# views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db import transaction
from apps.ticket.models import Train, TicketStock, Order
from apps.user.models import User
from utils.redis_utils import cache_ticket_stock
class BuyTicketView(APIView):
permission_classes = [IsAuthenticated] # 需登录
@transaction.atomic # 事务保证原子性
def post(self, request):
train_no = request.data.get('train_no')
seat_type = request.data.get('seat_type', '二等座')
user = request.user
try:
# 1. 获取车次和余票,加行级锁(select_for_update)
train = Train.objects.get(train_no=train_no)
ticket_stock = TicketStock.objects.select_for_update().get(train=train)
# 2. 检查余票
if ticket_stock.remaining_tickets <= 0:
return Response({"code": 400, "msg": "余票不足"})
# 3. 乐观锁扣减余票(对比当前余票和DB中的余票,防止并发修改)
affected_rows = TicketStock.objects.filter(
train=train,
remaining_tickets=ticket_stock.remaining_tickets # 乐观锁:确保扣减前数据未被修改
).update(remaining_tickets=ticket_stock.remaining_tickets - 1)
if affected_rows == 0:
return Response({"code": 400, "msg": "购票失败,请重试"})
# 4. 创建订单
order = Order.objects.create(
order_no=f"ORDER{train_no}{user.id}{int(time.time())}",
user=user,
train=train,
seat_type=seat_type,
price=train.price,
status="待支付"
)
# 5. 更新Redis缓存
cache_ticket_stock(train_no, ticket_stock.remaining_tickets - 1)
# 6. 启动Celery定时任务:15分钟未支付则取消订单
from apps.order.tasks import cancel_unpaid_order
cancel_unpaid_order.apply_async(args=[order.id], countdown=15*60)
return Response({"code": 200, "msg": "下单成功", "data": {"order_no": order.order_no}})
except (Train.DoesNotExist, TicketStock.DoesNotExist):
return Response({"code": 404, "msg": "车次不存在"})
except Exception as e:
return Response({"code": 500, "msg": f"购票失败:{str(e)}"})
(2)Celery异步任务(取消超时订单)
# tasks.py
from celery import shared_task
from apps.order.models import Order
from apps.ticket.models import TicketStock
from utils.redis_utils import cache_ticket_stock
@shared_task
def cancel_unpaid_order(order_id):
"""取消15分钟未支付的订单"""
try:
order = Order.objects.get(id=order_id, status="待支付")
# 恢复余票
ticket_stock = TicketStock.objects.get(train=order.train)
ticket_stock.remaining_tickets += 1
ticket_stock.save()
# 更新Redis缓存
cache_ticket_stock(order.train.train_no, ticket_stock.remaining_tickets)
# 修改订单状态
order.status = "已取消"
order.save()
except Order.DoesNotExist:
pass
except Exception as e:
print(f"取消订单失败:{str(e)}")
3. 支付模块:对接支付宝沙箱
支付功能用的是支付宝沙箱环境(开发测试用),流程是:前端发起支付请求→后端生成支付链接→前端跳转支付宝扫码支付→支付宝回调后端接口→后端更新订单状态。
(1)Django支付回调接口
# views.py
from alipay import AliPay
from django.conf import settings
from django.http import HttpResponse
class AlipayCallbackView(APIView):
def post(self, request):
# 接收支付宝回调参数
data = request.POST.dict()
signature = data.pop('sign')
# 初始化支付宝对象
alipay = AliPay(
appid=settings.ALIPAY_APPID,
app_notify_url=settings.ALIPAY_NOTIFY_URL,
app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(),
alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(),
sign_type="RSA2"
)
# 验证签名
success = alipay.verify(data, signature)
if success and data["trade_status"] in ("TRADE_SUCCESS", "TRADE_FINISHED"):
# 更新订单状态为“已支付”
order_no = data["out_trade_no"]
Order.objects.filter(order_no=order_no).update(status="已支付")
return HttpResponse("success") # 支付宝要求返回success,否则会重复回调
return HttpResponse("fail")
三、技术难点与解决方案:我踩过的3个大坑
1. 坑1:12306爬取数据被封IP
一开始用普通requests爬取车次数据,爬几次就被12306封IP了。解决方法:
用加代理池(免费代理不稳定,后来用了付费的);模拟浏览器请求头(User-Agent、Referer),避免被识别为爬虫;降低爬取频率(每爬一次sleep 1-2秒)。
requests-proxies
2. 坑2:并发下单导致超卖
前面已经说过,一开始没加锁,出现超卖。最终解决方案是:
用Django的加行级锁,保证同一车次的余票操作串行;配合乐观锁(
select_for_update()),进一步防止并发冲突。
filter(remaining_tickets=当前值).update()
3. 坑3:支付回调接收不到
一开始把回调地址设为localhost,支付宝肯定访问不到。解决方法:
用把本地端口映射到公网(ngrok http 8000);把支付宝沙箱的回调地址改成ngrok分配的公网地址(如https://xxxx.ngrok.io/api/order/alipay/callback)。
ngrok
四、部署上线:Docker容器化
最后用Docker把项目打包部署,写了,一键启动所有服务:
docker-compose.yml
# docker-compose.yml
version: '3'
services:
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: ticket_system
volumes:
- ./mysql_data:/var/lib/mysql
ports:
- "3306:3306"
redis:
image: redis:7.0
restart: always
ports:
- "6379:6379"
web:
build: ./backend
restart: always
depends_on:
- db
- redis
environment:
- DJANGO_SETTINGS_MODULE=settings.prod
command: gunicorn ticket_system.wsgi:application --bind 0.0.0.0:8000
celery:
build: ./backend
restart: always
depends_on:
- redis
command: celery -A ticket_system worker -l info
frontend:
build: ./frontend
restart: always
ports:
- "80:80"
depends_on:
- web
nginx:
image: nginx:1.23
restart: always
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
ports:
- "8080:80"
depends_on:
- web
- frontend
五、总结:从项目中收获的3个经验
技术选型要匹配业务场景:小Demo用Flask,复杂业务用Django,别为了“炫技”选不熟悉的技术;并发问题一定要提前考虑:购票、秒杀这类场景,超卖是致命问题,乐观锁/悲观锁要选对;接口设计要考虑扩展性:比如车次查询加了日期参数,后续扩展“返程票”功能就很方便。


